mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Compare commits
255 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a314a62f1f | |||
| 37d0041a4f | |||
| 3647f1a1ea | |||
| 7b5049165a | |||
| 3839b46edc | |||
| b7f2bca931 | |||
| 802bf77bc5 | |||
| fd7b49ab93 | |||
| 456070491f | |||
| c12beca4d7 | |||
| 910981645b | |||
| 82b5429d02 | |||
| 6c6f739fc0 | |||
| 001dafecb7 | |||
| 4bb3ada779 | |||
| 0b63dcb532 | |||
| 23dd220b2f | |||
| 770c293257 | |||
| 3374ab4779 | |||
| 7a668c0cf9 | |||
| 14151f6f5a | |||
| 0275e66eda | |||
| 9bc5842493 | |||
| 4cad8aba64 | |||
| 7b5af69dd1 | |||
| 2e179b74ba | |||
| fe2a2dfb43 | |||
| 910a36857e | |||
| 58ddd9b991 | |||
| bb9c6ab969 | |||
| ddffdd1b27 | |||
| f097620c4b | |||
| 280d790f0c | |||
| 3a2e74b559 | |||
| 2bf5f36a77 | |||
| b9ef0fa338 | |||
| 6d8cdeffe9 | |||
| 2e36247a1a | |||
| 37cd990b4f | |||
| bdc7ac4d22 | |||
| b6c2f36dd1 | |||
| 10cc4bee72 | |||
| ff6bcf019b | |||
| bb0508d456 | |||
| d6dd5e6709 | |||
| 3c75a7631a | |||
| e3d3e744b0 | |||
| 5767a078d9 | |||
| 67c7e85ba8 | |||
| c5a5582147 | |||
| 46cb8a7d61 | |||
| 0189733dec | |||
| d0c3581f57 | |||
| e7569b7448 | |||
| 69b998a61a | |||
| c50c110005 | |||
| 989e30fbed | |||
| 3775bb6093 | |||
| 3dc64d01db | |||
| 2ed8ecbbb7 | |||
| e5f6ef8a85 | |||
| 7ff0891ad5 | |||
| a9a5098a21 | |||
| 7f9bb8e11d | |||
| bef3f365bb | |||
| a0e8c60082 | |||
| e7f378823c | |||
| ba3cb98e2c | |||
| 7643645643 | |||
| 3b1e7e3b03 | |||
| 725111dc0c | |||
| 073d7eee03 | |||
| cce5a8df43 | |||
| 978067ac6c | |||
| 6255367366 | |||
| af9cb8ec6b | |||
| e0a1922ed4 | |||
| 4b5ad0dcab | |||
| ca62b31aa6 | |||
| 4ec32c51b2 | |||
| cdee616e18 | |||
| 50378a2ee2 | |||
| ab093467c4 | |||
| 79e41d8a6f | |||
| 35001ff422 | |||
| 40139cd636 | |||
| 7026619249 | |||
| 3945142966 | |||
| b19099cea2 | |||
| f65593de25 | |||
| a5f1a6ea75 | |||
| 4e58f20ba3 | |||
| dc41d6ce73 | |||
| 8869c9df2c | |||
| f2b3f2b584 | |||
| 31cea258a7 | |||
| 53d7439300 | |||
| 28a1852de8 | |||
| 8c03f10043 | |||
| ff92073d19 | |||
| 6ffc2c2806 | |||
| 9e402e373c | |||
| 2e4c19b714 | |||
| 039c926e2d | |||
| e52ba7b394 | |||
| 90dc7c80f2 | |||
| 15c883ca73 | |||
| 47f74b8842 | |||
| ef9009b304 | |||
| 89a6e51b48 | |||
| ce25758a17 | |||
| 371b236e25 | |||
| a54dd1fa9e | |||
| 31205a44f9 | |||
| 3c9c55e049 | |||
| 7a4f93cf0c | |||
| a738d58c37 | |||
| 46daed8fc4 | |||
| 29347c24f4 | |||
| f6727dc4dc | |||
| a0f603b707 | |||
| 631e3959cd | |||
| 2a340a26f9 | |||
| e75246ff8d | |||
| 64aee33452 | |||
| 1851f0e12f | |||
| 8b3f44708d | |||
| a5fd97a175 | |||
| 1284b22345 | |||
| 2ee5d1f7bd | |||
| 6eb257705f | |||
| 9ea1d06972 | |||
| ff8833b5b3 | |||
| 2dd98fd7e3 | |||
| 76fff98d9d | |||
| 18eeabd353 | |||
| 06b5a97de3 | |||
| 5cccc0b3c6 | |||
| 7ab9518a55 | |||
| ac51229398 | |||
| 5a2532a0fa | |||
| f9d2a875e2 | |||
| 6cf8e463c6 | |||
| 4206408db1 | |||
| ff2ed8757f | |||
| f73ea182ae | |||
| 0c5ee08f90 | |||
| bbf9581d3a | |||
| 5830ab4c67 | |||
| a1a0b71814 | |||
| 2b3b6b9549 | |||
| 047266b6d8 | |||
| be3034a94e | |||
| a11d05e720 | |||
| cb454e7eb7 | |||
| a6d6c53069 | |||
| c875ebd951 | |||
| a369386922 | |||
| b3198a44e9 | |||
| b2dfb8fec6 | |||
| d4d77bb13a | |||
| 7dfa5233f3 | |||
| 3d910f78db | |||
| 2dfac0be72 | |||
| afe0d2161d | |||
| 68c13c48c7 | |||
| b9a1e94a29 | |||
| d8c6a90c55 | |||
| 4d01ad7d1d | |||
| c487e7f53e | |||
| a316120a78 | |||
| 9af0537587 | |||
| f668bcecb8 | |||
| 6b95edfb72 | |||
| a12b09eb5f | |||
| cfb96c45c9 | |||
| 747b0f9c2c | |||
| ee2f530d81 | |||
| 617124efe4 | |||
| c0337f4d67 | |||
| e5dcca3408 | |||
| f2b05856bb | |||
| 5d6aaace86 | |||
| 9dcb3d7269 | |||
| e96bb46cfd | |||
| 37edc957d2 | |||
| 60df577cc6 | |||
| e0e2b0c406 | |||
| 244be32b59 | |||
| 1080a26f93 | |||
| 4b62b02a13 | |||
| c12bf92723 | |||
| 5c5b49d0a9 | |||
| b7f886b51e | |||
| 587266e23d | |||
| 9293b6321f | |||
| ddfd1206a7 | |||
| 75910960c5 | |||
| aae633edee | |||
| f129329d52 | |||
| 2afcc5d1c9 | |||
| 7f578c5d03 | |||
| 180b129550 | |||
| 8b2277c8c3 | |||
| 68f4562395 | |||
| c374a4a4e9 | |||
| bda66381b8 | |||
| 28adeee7bd | |||
| 727ac8ccdb | |||
| b77a8ef56f | |||
| 50e0ccd9e4 | |||
| e43a25307f | |||
| db4750217e | |||
| 19793cdcd4 | |||
| 15bddc43e2 | |||
| 3b7c7bb13f | |||
| 633cece581 | |||
| e455d203cc | |||
| d2a5229282 | |||
| 2391d6ceeb | |||
| 4bb57ed0a0 | |||
| 5bf3d32636 | |||
| 12a50c6100 | |||
| c5a0cfe118 | |||
| 898bbd57ec | |||
| 5b5113de6e | |||
| 267a6f37cc | |||
| 7d4898c266 | |||
| f49822d03d | |||
| aa4da686c6 | |||
| 5a668c469f | |||
| 2ca733de97 | |||
| 8afc1a6381 | |||
| d47142153e | |||
| 09537d84d0 | |||
| 188385b638 | |||
| 08aa79a06b | |||
| 16741aaa46 | |||
| 93083c7d2a | |||
| 8333b5138a | |||
| c0ee2013f3 | |||
| 022656cd80 | |||
| 9d3c22fcf3 | |||
| 11353809f0 | |||
| 6463b7a572 | |||
| 7a5ee2aca1 | |||
| 5e907d7e53 | |||
| 1aa2ca9b31 | |||
| c87107b4ee | |||
| 55b13988bf | |||
| 19033278b3 | |||
| 4a6ac8a57d | |||
| 2b9847e1a9 | |||
| 167769a711 | |||
| 417dbba458 |
+21
-2
@@ -30,6 +30,10 @@ default:
|
||||
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
|
||||
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
|
||||
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
|
||||
- echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV"
|
||||
- echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL"
|
||||
- echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
|
||||
- echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
|
||||
- echo "Building Next.js static export..."
|
||||
- npx next build
|
||||
- |
|
||||
@@ -41,7 +45,11 @@ default:
|
||||
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
|
||||
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
|
||||
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
|
||||
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL",
|
||||
"NEXT_PUBLIC_APP_ENV": "$NEXT_PUBLIC_APP_ENV",
|
||||
"NEXT_PUBLIC_HELPDESK_URL": "$NEXT_PUBLIC_HELPDESK_URL",
|
||||
"NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL": "$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
|
||||
"NEXT_PUBLIC_S3_PUBLIC_BASE_URL": "NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
|
||||
}
|
||||
EOF
|
||||
artifacts:
|
||||
@@ -142,6 +150,10 @@ build:dev:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'development'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/'
|
||||
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com'
|
||||
|
||||
deploy:dev:
|
||||
<<: *deploy_template
|
||||
@@ -170,6 +182,9 @@ build:staging:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'staging'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/'
|
||||
|
||||
deploy:staging:
|
||||
<<: *deploy_template
|
||||
@@ -185,7 +200,7 @@ deploy:staging:
|
||||
url: https://stg-lti-erp.mbugroup.id
|
||||
|
||||
# ==========================================================
|
||||
# ====== STAGING (Branch production) ======
|
||||
# ====== (Branch production) ======
|
||||
# ==========================================================
|
||||
build:production:
|
||||
<<: *build_template
|
||||
@@ -198,6 +213,10 @@ build:production:
|
||||
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
|
||||
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
|
||||
NEXT_PUBLIC_APP_ENV: 'production'
|
||||
NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/'
|
||||
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/'
|
||||
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/'
|
||||
|
||||
deploy:production:
|
||||
<<: *deploy_template
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
npm run format
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run typecheck
|
||||
git add .
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Project-local RTK filters — commit this file with your repo.
|
||||
# Filters here override user-global and built-in filters.
|
||||
# Docs: https://github.com/rtk-ai/rtk#custom-filters
|
||||
schema_version = 1
|
||||
|
||||
# Example: suppress build noise from a custom tool
|
||||
# [filters.my-tool]
|
||||
# description = "Compact my-tool output"
|
||||
# match_command = "^my-tool\\s+build"
|
||||
# strip_ansi = true
|
||||
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
|
||||
# max_lines = 30
|
||||
# on_empty = "my-tool: ok"
|
||||
@@ -0,0 +1,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 -->
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -523,7 +523,7 @@ const useSelect = <T,>(
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
...(params ?? {}),
|
||||
[searchKey]: inputValue ?? '',
|
||||
[searchKey ? searchKey : 'search']: inputValue ?? '',
|
||||
[pageKey]: String(pageIndex + 1),
|
||||
[limitKey]: String(limit),
|
||||
}).toString();
|
||||
|
||||
@@ -69,6 +69,7 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
|
||||
secondaryButton={
|
||||
secondaryButton
|
||||
? {
|
||||
...secondaryButton,
|
||||
text: secondaryButton?.text ?? 'Tidak',
|
||||
onClick: (e) => {
|
||||
if (secondaryButton && secondaryButton?.onClick) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Button from '@/components/Button';
|
||||
@@ -15,6 +16,7 @@ interface ExpenseDetailProps {
|
||||
}
|
||||
|
||||
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<string>('request');
|
||||
|
||||
const expenseDetailTabs = useMemo(() => {
|
||||
@@ -46,8 +48,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
|
||||
<section className='w-full max-w-full pb-16'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
variant='link'
|
||||
onClick={router.back}
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
<Icon icon='uil:arrow-left' width={24} height={24} />
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
|
||||
interface ExpenseRealizationContentProps {
|
||||
@@ -25,6 +29,8 @@ interface ExpenseRealizationContentProps {
|
||||
const ExpenseRealizationContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationContentProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
initialValues: {
|
||||
documents: [],
|
||||
@@ -74,7 +80,11 @@ const ExpenseRealizationContent = ({
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -19,6 +20,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
@@ -26,11 +28,15 @@ import {
|
||||
UploadRequestDocumentsFormSchema,
|
||||
UploadRequestDocumentsFormValues,
|
||||
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
|
||||
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import {
|
||||
buildExpenseActionHref,
|
||||
getExpenseListReturnTo,
|
||||
} from '@/lib/expense-list-navigation';
|
||||
|
||||
interface ExpenseRequestContentProps {
|
||||
initialValues?: Expense;
|
||||
@@ -40,6 +46,13 @@ const ExpenseRequestContent = ({
|
||||
initialValues,
|
||||
}: ExpenseRequestContentProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const refreshExpense = () => {
|
||||
mutate((key) => Array.isArray(key) && key[0] === 'expense-detail');
|
||||
};
|
||||
|
||||
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
|
||||
useApprovalSteps({
|
||||
@@ -89,17 +102,24 @@ const ExpenseRequestContent = ({
|
||||
!isLatestApprovalRejected &&
|
||||
initialValues?.latest_approval.step_number === 4;
|
||||
|
||||
const isExpensePaidOff = initialValues?.is_paid;
|
||||
|
||||
const showPaidOffButton =
|
||||
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
|
||||
|
||||
// Modal hooks
|
||||
const deleteModal = useModal();
|
||||
const completeModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const paidOffModal = useModal();
|
||||
|
||||
// Modal loading state
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isRejectLoading, setIsRejectLoading] = useState(false);
|
||||
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
|
||||
const [, setApprovalNotes] = useState('');
|
||||
|
||||
const formik = useFormik<UploadRequestDocumentsFormValues>({
|
||||
@@ -140,7 +160,31 @@ const ExpenseRequestContent = ({
|
||||
rejectModal.openModal();
|
||||
};
|
||||
|
||||
const paidOffClickHandler = () => {
|
||||
paidOffModal.openModal();
|
||||
};
|
||||
|
||||
// Modal confirm click handler
|
||||
const confirmationModalPaidOffClickHandler = async () => {
|
||||
setIsPaidOffLoading(true);
|
||||
|
||||
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
|
||||
initialValues?.id as number
|
||||
);
|
||||
|
||||
if (isResponseSuccess(paidOffResponse)) {
|
||||
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
|
||||
refreshExpense();
|
||||
} else {
|
||||
toast.error(
|
||||
'Gagal menandai biaya operasional sebagai lunas!: ' +
|
||||
paidOffResponse?.message
|
||||
);
|
||||
}
|
||||
|
||||
paidOffModal.closeModal();
|
||||
setIsPaidOffLoading(false);
|
||||
};
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
@@ -148,7 +192,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(deleteResponse)) {
|
||||
toast.success('Berhasil menghapus data biaya operasional!');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error('Gagal menghapus data biaya operasional!');
|
||||
}
|
||||
@@ -164,7 +208,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
if (isResponseSuccess(completeRes)) {
|
||||
toast.success(completeRes.message);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
toast.error(completeRes?.message as string);
|
||||
}
|
||||
@@ -204,7 +248,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(approveResponse?.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
approveModal.closeModal();
|
||||
|
||||
@@ -239,7 +283,7 @@ const ExpenseRequestContent = ({
|
||||
|
||||
toast.success(rejectResponse.message);
|
||||
setApprovalNotes('');
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
} else {
|
||||
rejectModal.closeModal();
|
||||
|
||||
@@ -365,7 +409,11 @@ const ExpenseRequestContent = ({
|
||||
<Button
|
||||
variant='outline'
|
||||
color='info'
|
||||
href={`/expense/realization/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/realization/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
@@ -378,13 +426,35 @@ const ExpenseRequestContent = ({
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{showPaidOffButton && (
|
||||
<RequirePermission permissions='lti.expense.create.realization'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='success'
|
||||
onClick={paidOffClickHandler}
|
||||
className='w-full sm:w-fit'
|
||||
>
|
||||
<Icon
|
||||
icon='material-symbols:check-circle-outline'
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Tandai Lunas
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
|
||||
{showEditButton && (
|
||||
<RequirePermission permissions='lti.expense.update'>
|
||||
<Button
|
||||
type='button'
|
||||
color='warning'
|
||||
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
|
||||
href={buildExpenseActionHref(
|
||||
'/expense/detail/edit/',
|
||||
initialValues?.id as number,
|
||||
searchParams
|
||||
)}
|
||||
className='px-4 grow sm:grow-0'
|
||||
>
|
||||
<Icon icon='mdi:pencil-outline' width={24} height={24} />
|
||||
@@ -519,6 +589,19 @@ const ExpenseRequestContent = ({
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status Lunas</th>
|
||||
<th>:</th>
|
||||
<td>
|
||||
<StatusBadge
|
||||
color={initialValues?.is_paid ? 'primary' : 'warning'}
|
||||
text={initialValues?.is_paid ? 'Lunas' : 'Belum Lunas'}
|
||||
className={{
|
||||
badge: 'w-fit whitespace-nowrap',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Dokumen Pengajuan</th>
|
||||
<th>:</th>
|
||||
@@ -534,21 +617,15 @@ const ExpenseRequestContent = ({
|
||||
<ul className='list-disc'>
|
||||
{initialValues?.documents.map(
|
||||
(requestDocument, requestDocumentIdx) => {
|
||||
const path = requestDocument.path.startsWith(
|
||||
'/'
|
||||
)
|
||||
? requestDocument.path.slice(1)
|
||||
: requestDocument.path;
|
||||
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
|
||||
return (
|
||||
<li key={requestDocumentIdx}>
|
||||
<Link
|
||||
href={documentUrl}
|
||||
href={requestDocument.path}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 underline'
|
||||
>
|
||||
{requestDocument.path}{' '}
|
||||
{requestDocument.name}{' '}
|
||||
<Icon
|
||||
icon='cuida:open-in-new-tab-outline'
|
||||
width={12}
|
||||
@@ -744,6 +821,21 @@ const ExpenseRequestContent = ({
|
||||
onClick: confirmationModalRejectClickHandler,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
ref={paidOffModal.ref}
|
||||
type='success'
|
||||
text='Apakah anda yakin ingin menandai biaya operasional ini sebagai lunas?'
|
||||
secondaryButton={{
|
||||
text: 'Tidak',
|
||||
}}
|
||||
primaryButton={{
|
||||
text: 'Ya',
|
||||
color: 'success',
|
||||
isLoading: isPaidOffLoading,
|
||||
onClick: confirmationModalPaidOffClickHandler,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,26 +3,60 @@ import * as yup from 'yup';
|
||||
export type ExpensesFilterType = {
|
||||
transaction_date: string | null;
|
||||
realization_date: string | null;
|
||||
location_id: string | null;
|
||||
vendor_id: string | null;
|
||||
location: { value: number; label: string } | null;
|
||||
vendor: { value: number; label: string } | null;
|
||||
category: { value: string; label: string } | null;
|
||||
approval_status: { value: string; label: string } | null;
|
||||
realization_status: { value: string; label: string } | null;
|
||||
project_flock: { value: number; label: string } | null;
|
||||
project_flock_kandang: { value: number; label: string } | null;
|
||||
};
|
||||
|
||||
export const ExpensesFilterSchema = yup.object({
|
||||
transaction_date: yup.string().nullable(),
|
||||
realization_date: yup
|
||||
.string()
|
||||
.nullable()
|
||||
.test(
|
||||
'is-greater-or-equal-transaction',
|
||||
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
|
||||
function (value) {
|
||||
const { transaction_date } = this.parent;
|
||||
if (!transaction_date || !value) return true;
|
||||
return new Date(value) >= new Date(transaction_date);
|
||||
}
|
||||
),
|
||||
location_id: yup.string().nullable(),
|
||||
vendor_id: yup.string().nullable(),
|
||||
realization_date: yup.string().nullable(),
|
||||
location: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
vendor: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
category: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
approval_status: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
realization_status: yup
|
||||
.object({
|
||||
value: yup.string().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
project_flock: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
project_flock_kandang: yup
|
||||
.object({
|
||||
value: yup.number().required(),
|
||||
label: yup.string().required(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject } from 'react';
|
||||
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -11,8 +11,11 @@ import SelectInput from '@/components/input/SelectInput';
|
||||
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
ExpensesFilterSchema,
|
||||
ExpensesFilterValues,
|
||||
@@ -31,64 +34,143 @@ const ExpensesFilterModal = ({
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: ExpensesFilterModalProps) => {
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string>(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'BOP', label: 'BOP' },
|
||||
{ value: 'NON-BOP', label: 'NON-BOP' },
|
||||
];
|
||||
|
||||
const approvalStatusOptions = [
|
||||
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
|
||||
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
|
||||
{ value: 'FINANCE', label: 'Approval Finance' },
|
||||
{ value: 'REALISASI', label: 'Realisasi' },
|
||||
{ value: 'SELESAI', label: 'Selesai' },
|
||||
{ value: 'DITOLAK', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const realizationStatusOptions = [
|
||||
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
|
||||
{ value: 'REALIZED', label: 'Sudah Realisasi' },
|
||||
{ value: 'REJECTED', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setVendorInputValue,
|
||||
options: vendorOptions,
|
||||
isLoadingOptions: isLoadingVendorOptions,
|
||||
loadMore: loadMoreVendors,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
rawData: projectFlocksRawData,
|
||||
options: projectFlockOptions,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationId || '',
|
||||
}
|
||||
);
|
||||
|
||||
const formik = useFormik<ExpensesFilterValues>({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues || {
|
||||
transaction_date: null,
|
||||
realization_date: null,
|
||||
location_id: null,
|
||||
vendor_id: null,
|
||||
location: null,
|
||||
vendor: null,
|
||||
category: null,
|
||||
approval_status: null,
|
||||
realization_status: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
validationSchema: ExpensesFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const locationValue = formik.values.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
: null;
|
||||
useEffect(() => {
|
||||
setSelectedLocationId(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
}, [initialValues?.location]);
|
||||
|
||||
const vendorValue = formik.values.vendor_id
|
||||
? vendorOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.vendor_id
|
||||
) || null
|
||||
: null;
|
||||
const { resetForm } = formik;
|
||||
|
||||
const formikResetHandler = useCallback(() => {
|
||||
resetForm({
|
||||
values: {
|
||||
transaction_date: null,
|
||||
realization_date: null,
|
||||
location: null,
|
||||
vendor: null,
|
||||
category: null,
|
||||
approval_status: null,
|
||||
realization_status: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
});
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
}, [resetForm, onReset, closeModalHandler]);
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const locationId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
const value = val as OptionType | null;
|
||||
formik.setFieldValue('location', value);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedLocationId(value?.value ? String(value.value) : '');
|
||||
};
|
||||
|
||||
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const vendorId =
|
||||
val && !Array.isArray(val) ? (String(val.value) as string) : null;
|
||||
formik.setFieldValue('vendor_id', vendorId);
|
||||
formik.setFieldValue('vendor', val as OptionType | null);
|
||||
};
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
@@ -98,7 +180,7 @@ const ExpensesFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -121,49 +203,41 @@ const ExpensesFilterModal = ({
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='py-2 text-xs font-semibold'>Tanggal</span>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<DateInput
|
||||
name='transaction_date'
|
||||
placeholder='Tanggal Transaksi'
|
||||
value={formik.values.transaction_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
!!formik.errors.transaction_date
|
||||
}
|
||||
/>
|
||||
<hr className='w-full max-w-3 h-px border-base-content/10' />
|
||||
<DateInput
|
||||
name='realization_date'
|
||||
placeholder='Tanggal Realisasi'
|
||||
value={formik.values.realization_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.realization_date &&
|
||||
!!formik.errors.realization_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formik.touched.realization_date &&
|
||||
formik.errors.realization_date && (
|
||||
<span className='text-xs text-error'>
|
||||
{formik.errors.realization_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DateInput
|
||||
name='transaction_date'
|
||||
label='Tanggal Transaksi'
|
||||
placeholder='Tanggal Transaksi'
|
||||
value={formik.values.transaction_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.transaction_date &&
|
||||
!!formik.errors.transaction_date
|
||||
}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='realization_date'
|
||||
label='Tanggal Realisasi'
|
||||
placeholder='Tanggal Realisasi'
|
||||
value={formik.values.realization_date || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.realization_date &&
|
||||
!!formik.errors.realization_date
|
||||
}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationValue}
|
||||
value={formik.values.location}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -173,14 +247,87 @@ const ExpensesFilterModal = ({
|
||||
label='Vendor'
|
||||
placeholder='Pilih Vendor'
|
||||
options={vendorOptions}
|
||||
value={vendorValue}
|
||||
value={formik.values.vendor}
|
||||
onChange={vendorChangeHandler}
|
||||
onInputChange={setVendorInputValue}
|
||||
isLoading={isLoadingVendorOptions}
|
||||
onMenuScrollToBottom={loadMoreVendors}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={formik.values.category}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('category', val as OptionType | null)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status BOP'
|
||||
placeholder='Pilih Status BOP'
|
||||
options={approvalStatusOptions}
|
||||
value={formik.values.approval_status}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue('approval_status', val as OptionType | null)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status Pencairan'
|
||||
placeholder='Pilih Status Pencairan'
|
||||
options={realizationStatusOptions}
|
||||
value={formik.values.realization_status}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'realization_status',
|
||||
val as OptionType | null
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('project_flock', val as OptionType | null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
options={projectFlockKandangOptions}
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
val as OptionType | null
|
||||
)
|
||||
}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -35,6 +35,7 @@ import { isResponseError } from '@/lib/api-helper';
|
||||
import { LocationApi, SupplierApi } from '@/services/api/master-data';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
|
||||
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
|
||||
|
||||
@@ -48,6 +49,8 @@ const ExpenseRealizationForm = ({
|
||||
initialValues,
|
||||
}: ExpenseRealizationFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnTo = getExpenseListReturnTo(searchParams);
|
||||
|
||||
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
|
||||
|
||||
@@ -64,9 +67,9 @@ const ExpenseRealizationForm = ({
|
||||
}
|
||||
|
||||
toast.success(createExpenseRes?.message as string);
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[initialValues?.id, returnTo, router]
|
||||
);
|
||||
|
||||
const updateExpenseHandler = useCallback(
|
||||
@@ -83,9 +86,9 @@ const ExpenseRealizationForm = ({
|
||||
|
||||
toast.success(updateExpenseRes?.message as string);
|
||||
router.refresh();
|
||||
router.push('/expense');
|
||||
router.push(returnTo);
|
||||
},
|
||||
[router]
|
||||
[returnTo, router]
|
||||
);
|
||||
|
||||
const formik = useFormik<ExpenseRealizationFormValues>({
|
||||
@@ -258,7 +261,7 @@ const ExpenseRealizationForm = ({
|
||||
<section className='w-full'>
|
||||
<header className='flex flex-col gap-4'>
|
||||
<Button
|
||||
href='/expense'
|
||||
href={returnTo}
|
||||
variant='link'
|
||||
className='w-fit p-0 text-primary'
|
||||
>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/react-table';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -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',
|
||||
|
||||
@@ -849,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';
|
||||
@@ -17,20 +17,31 @@ import {
|
||||
import { MarketingFilter } from '@/types/api/marketing/marketing';
|
||||
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
|
||||
import { MarketingApi } from '@/services/api/marketing/marketing';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { CustomerApi, ProductApi } 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();
|
||||
@@ -38,36 +49,13 @@ const MarketingFilterModal = ({
|
||||
|
||||
// ===== OPTIONS =====
|
||||
const {
|
||||
rawData: productsRawData,
|
||||
options: productsOptions,
|
||||
isLoadingOptions: isLoadingProductsOptions,
|
||||
setInputValue: setProductsInputValue,
|
||||
loadMore: loadMoreProducts,
|
||||
} = useSelect<BaseMarketing>(
|
||||
MarketingApi.basePath,
|
||||
'id',
|
||||
'so_number',
|
||||
'search'
|
||||
);
|
||||
|
||||
const productsOptions = useMemo(() => {
|
||||
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
|
||||
|
||||
const productsMap = new Map<number, { value: number; label: string }>();
|
||||
|
||||
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
|
||||
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
|
||||
const product = so.product_warehouse?.product;
|
||||
if (product?.id && product?.name) {
|
||||
productsMap.set(product.id, {
|
||||
value: product.id,
|
||||
label: product.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(productsMap.values());
|
||||
}, [productsRawData]);
|
||||
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
|
||||
include_all: 'true',
|
||||
});
|
||||
|
||||
const {
|
||||
options: customersOptions,
|
||||
@@ -78,6 +66,19 @@ const MarketingFilterModal = ({
|
||||
has_marketing: 'true',
|
||||
});
|
||||
|
||||
const {
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search'
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
...MARKETING_APPROVAL_LINE.map((item) => ({
|
||||
value: item.step_name.split(' ').join('_').toUpperCase(),
|
||||
@@ -87,18 +88,29 @@ const MarketingFilterModal = ({
|
||||
];
|
||||
|
||||
const formik = useFormik<MarketingFilterFormValues>({
|
||||
initialValues: {
|
||||
initialValues: initialValues || {
|
||||
product_ids: [],
|
||||
status: null,
|
||||
customer: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
validationSchema: MarketingFilterSchema,
|
||||
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues: MarketingFilter = {
|
||||
product_ids: values.product_ids.map((item) => Number(item.value)),
|
||||
product_names: values.product_ids.map((item) => item.label),
|
||||
status: values.status?.value.toString() || '',
|
||||
status_name: values.status?.label || '-',
|
||||
customer_id: Number(values.customer?.value),
|
||||
customer_name: values.customer?.label || '-',
|
||||
project_flock_id: values.project_flock?.value || undefined,
|
||||
project_flock_name: values.project_flock?.label,
|
||||
project_flock_kandang_id:
|
||||
Number(values.project_flock_kandang?.value) || undefined,
|
||||
project_flock_kandang_name:
|
||||
values.project_flock_kandang?.label || undefined,
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
@@ -111,6 +123,22 @@ 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[]);
|
||||
};
|
||||
@@ -126,6 +154,27 @@ const MarketingFilterModal = ({
|
||||
formik.setFieldValue('status', val as OptionType);
|
||||
};
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
@@ -135,7 +184,7 @@ const MarketingFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -192,6 +241,37 @@ const MarketingFilterModal = ({
|
||||
onInputChange={setCustomersInputValue}
|
||||
onMenuScrollToBottom={loadMoreCustomers}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
isClearable
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue(
|
||||
'project_flock',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
/>
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
isClearable
|
||||
placeholder='Pilih Kandang'
|
||||
options={projectFlockKandangOptions}
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
!Array.isArray(val) ? (val as OptionType<number> | null) : null
|
||||
)
|
||||
}
|
||||
isDisabled={!formik.values.project_flock}
|
||||
/>
|
||||
</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={{
|
||||
@@ -777,6 +1294,7 @@ const MarketingTable = () => {
|
||||
ref={filterModal.ref}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
initialValues={marketingFilterInitialValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -246,6 +246,7 @@ const SalesOrderFormModal = ({
|
||||
})
|
||||
.filter((item) => Boolean(item)),
|
||||
} as UpdateDeliveryOrderPayload);
|
||||
|
||||
switch (modalAction) {
|
||||
case 'add':
|
||||
await createMarketingHandler(payload as CreateSalesOrderPayload);
|
||||
@@ -261,11 +262,7 @@ const SalesOrderFormModal = ({
|
||||
|
||||
// ===== Formik Error List =====
|
||||
const { formErrorList, setFormErrorList, close, handleFormSubmit } =
|
||||
useFormikErrorList(formik, {
|
||||
onAfterSubmit: () => {
|
||||
router.push('/marketing');
|
||||
},
|
||||
});
|
||||
useFormikErrorList(formik);
|
||||
|
||||
// ================== FORM REPEATER HANDLER ==================
|
||||
const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
|
||||
|
||||
@@ -5,10 +5,14 @@ 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;
|
||||
};
|
||||
|
||||
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
|
||||
.required('Pengiriman wajib diisi!')
|
||||
.test(
|
||||
'at-least-one-valid-row',
|
||||
'Minimal harus ada satu baris pengiriman yang lengkap diisi!',
|
||||
'Seluruh data pengiriman harus diisi lengkap!',
|
||||
function (items) {
|
||||
if (!items || items.length === 0) return false;
|
||||
|
||||
// VALIDASI: minimal 1 item valid full
|
||||
// VALIDASI: seluruh item harus valid full
|
||||
const itemSchema = DeliveryOrderProductSchema;
|
||||
|
||||
const hasValidItem = items.some((item) => {
|
||||
const hasValidItem = items.every((item) => {
|
||||
if (!item) return false;
|
||||
return itemSchema.isValidSync(item, { abortEarly: true });
|
||||
});
|
||||
@@ -123,8 +123,17 @@ export const SalesProductToFieldValues = (
|
||||
total_price: product.total_price,
|
||||
marketing_type: product.marketing_type
|
||||
? {
|
||||
value: product.marketing_type,
|
||||
label: formatTitleCase(product.marketing_type),
|
||||
value:
|
||||
product.marketing_type === 'AYAM' ||
|
||||
product.marketing_type === 'AYAM_PULLET'
|
||||
? 'AYAM,AYAM_PULLET'
|
||||
: product.marketing_type,
|
||||
label: formatTitleCase(
|
||||
product.marketing_type === 'AYAM' ||
|
||||
product.marketing_type === 'AYAM_PULLET'
|
||||
? 'AYAM'
|
||||
: product.marketing_type
|
||||
),
|
||||
}
|
||||
: null,
|
||||
convertion_unit: product.convertion_unit
|
||||
@@ -144,9 +153,11 @@ export const DeliveryProductToFieldValues = (
|
||||
delivery: BaseDeliveryOrder
|
||||
): DeliveryOrderProductFormValues[] => {
|
||||
const data = delivery.deliveries.map((item) => {
|
||||
const salesOrder = salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const salesOrder =
|
||||
salesOrders.find((so) => so.id === item.marketing_product_id) ??
|
||||
salesOrders.find(
|
||||
(so) => so.product_warehouse.id === item.product_warehouse.id
|
||||
);
|
||||
const warehouseOption = {
|
||||
value: item.product_warehouse.warehouse.id,
|
||||
label: item.product_warehouse.warehouse.name,
|
||||
@@ -180,11 +191,20 @@ export const DeliveryProductToFieldValues = (
|
||||
vehicle_number: item.vehicle_number,
|
||||
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
|
||||
do_number: delivery.do_number,
|
||||
marketing_product_id: salesOrder?.id,
|
||||
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
|
||||
marketing_type: salesOrder?.marketing_type
|
||||
? {
|
||||
value: salesOrder?.marketing_type,
|
||||
label: formatTitleCase(salesOrder?.marketing_type),
|
||||
value:
|
||||
salesOrder?.marketing_type === 'AYAM' ||
|
||||
salesOrder?.marketing_type === 'AYAM_PULLET'
|
||||
? 'AYAM,AYAM_PULLET'
|
||||
: salesOrder?.marketing_type,
|
||||
label: formatTitleCase(
|
||||
salesOrder?.marketing_type === 'AYAM' ||
|
||||
salesOrder?.marketing_type === 'AYAM_PULLET'
|
||||
? 'AYAM'
|
||||
: salesOrder?.marketing_type
|
||||
),
|
||||
}
|
||||
: null,
|
||||
convertion_unit: salesOrder?.convertion_unit
|
||||
@@ -194,7 +214,7 @@ export const DeliveryProductToFieldValues = (
|
||||
}
|
||||
: null,
|
||||
marketing_product: {
|
||||
id: salesOrder?.id,
|
||||
id: item.marketing_product_id ?? salesOrder?.id,
|
||||
vehicle_number: item.vehicle_number,
|
||||
warehouse_id: item.product_warehouse.warehouse.id,
|
||||
warehouse: warehouseOption,
|
||||
|
||||
+23
-23
@@ -146,15 +146,6 @@ const DeliveryOrderProductForm = ({
|
||||
);
|
||||
|
||||
// ============ Fetch Data ============
|
||||
const { data: productData } = useSWR(
|
||||
selectedProduct?.value
|
||||
? ProductApi.basePath + '/' + selectedProduct?.value
|
||||
: null,
|
||||
() =>
|
||||
selectedProduct?.value
|
||||
? ProductApi.getSingle(Number(selectedProduct?.value))
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Options Week dari minggu 1 - 22
|
||||
// const optionsWeek = useMemo(() => {
|
||||
@@ -190,12 +181,19 @@ const DeliveryOrderProductForm = ({
|
||||
const deliveryOrder = useMemo(() => {
|
||||
if (!hasDeliveryOrder || !deliveryOrders) return null;
|
||||
|
||||
const marketingProductId =
|
||||
initialValues?.marketing_product_id ?? initialValues?.id;
|
||||
|
||||
for (const doItem of deliveryOrders) {
|
||||
const found = doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
const found =
|
||||
doItem.deliveries.find(
|
||||
(d) => d.marketing_product_id === marketingProductId
|
||||
) ??
|
||||
doItem.deliveries.find(
|
||||
(d) =>
|
||||
d.product_warehouse.id ===
|
||||
initialValues?.marketing_product?.product_warehouse_id
|
||||
);
|
||||
if (found) {
|
||||
return {
|
||||
...found,
|
||||
@@ -403,7 +401,10 @@ const DeliveryOrderProductForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
if (!Boolean(initialValues.qty)) {
|
||||
if (
|
||||
!Boolean(initialValues.qty) &&
|
||||
!Boolean(initialValues.marketing_product_id)
|
||||
) {
|
||||
handleResetForm();
|
||||
} else {
|
||||
setFormikValues({
|
||||
@@ -413,7 +414,7 @@ const DeliveryOrderProductForm = ({
|
||||
});
|
||||
if (initialValues?.marketing_product_id) {
|
||||
setSelectedProduct({
|
||||
value: initialValues?.id,
|
||||
value: initialValues?.marketing_product_id,
|
||||
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
|
||||
} as OptionType);
|
||||
}
|
||||
@@ -430,7 +431,8 @@ const DeliveryOrderProductForm = ({
|
||||
handleBlurField(currentInput);
|
||||
formik.setFieldValue(
|
||||
'uom',
|
||||
isResponseSuccess(productData) ? productData?.data?.uom?.name : ''
|
||||
initialValues?.marketing_product?.product_warehouse_data?.product?.uom
|
||||
?.name ?? ''
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -803,9 +805,8 @@ const DeliveryOrderProductForm = ({
|
||||
endAdornment={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-sm text-gray-500'>
|
||||
{isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: ''}
|
||||
{initialValues?.marketing_product?.product_warehouse_data
|
||||
?.product?.uom?.name ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -816,9 +817,8 @@ const DeliveryOrderProductForm = ({
|
||||
(item) => item.id === formik.values.marketing_product_id
|
||||
)?.qty +
|
||||
' ' +
|
||||
(isResponseSuccess(productData)
|
||||
? productData?.data?.uom.name
|
||||
: '')
|
||||
(initialValues?.marketing_product?.product_warehouse_data
|
||||
?.product?.uom?.name ?? '')
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -252,6 +252,11 @@ const SalesOrderProductForm = ({
|
||||
setSelectedProductWarehouse(productWarehouse || null);
|
||||
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
|
||||
formik.setFieldValue('qty', productWarehouse?.quantity);
|
||||
|
||||
if (productWarehouse?.quantity) {
|
||||
handleFieldChange('qty', productWarehouse?.quantity);
|
||||
}
|
||||
|
||||
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
|
||||
if (
|
||||
productWarehouse?.week !== undefined &&
|
||||
|
||||
@@ -124,7 +124,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
@@ -273,7 +273,7 @@ const DeliveryOrderProductTable = ({
|
||||
<tr>
|
||||
<td className='text-sm px-4 py-3'>Qty</td>
|
||||
<td className='text-sm px-4 py-3'>
|
||||
{item.qty
|
||||
{item.qty !== undefined && item.qty !== null && item.qty !== ''
|
||||
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
|
||||
: '-'}
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const AreasTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const AreasTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'areas-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const AreasTable = () => {
|
||||
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('areas-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const BanksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const BanksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'banks-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const BanksTable = () => {
|
||||
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('banks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const CustomersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const CustomersTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'customers-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const CustomersTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('customers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -201,6 +189,11 @@ const CustomersTable = () => {
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank_name',
|
||||
header: 'Nama Bank',
|
||||
cell: (props) => props.row.original.bank_name || '-',
|
||||
},
|
||||
{
|
||||
header: 'Aksi',
|
||||
cell: (props: CellContext<Customer, unknown>) => {
|
||||
|
||||
@@ -27,6 +27,9 @@ export const CustomerFormSchema = Yup.object({
|
||||
.email('Format email tidak valid!')
|
||||
.required('Email wajib diisi!'),
|
||||
|
||||
bank_name: Yup.string()
|
||||
.min(3, 'Nama bank minimal 3 karakter!')
|
||||
.required('Nama bank wajib diisi!'),
|
||||
account_number: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||
.required('Nomor rekening wajib diisi!'),
|
||||
|
||||
@@ -142,6 +142,7 @@ const CustomerForm = ({
|
||||
},
|
||||
type: normalizeType(initialValues?.type),
|
||||
address: initialValues?.address ?? '',
|
||||
bank_name: initialValues?.bank_name ?? '',
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
};
|
||||
}, [initialValues]);
|
||||
@@ -164,6 +165,7 @@ const CustomerForm = ({
|
||||
pic_id: values.picId,
|
||||
type: (values.type as OptionType).value as string,
|
||||
address: values.address,
|
||||
bank_name: values.bank_name,
|
||||
account_number: values.account_number,
|
||||
};
|
||||
|
||||
@@ -286,6 +288,22 @@ const CustomerForm = ({
|
||||
errorMessage={formik.errors.phone}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nama Bank'
|
||||
name='bank_name'
|
||||
placeholder='Masukkan nama bank customer'
|
||||
value={formik.values.bank_name}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.bank_name && Boolean(formik.errors.bank_name)
|
||||
}
|
||||
errorMessage={formik.errors.bank_name}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nomor Rekening'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const FlockTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const FlockTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'flock-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const FlockTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('flocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
KandangFilterSchema,
|
||||
KandangFilterType,
|
||||
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const KandangsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
locationFilter?: OptionType<string>;
|
||||
picFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
locationFilter: '',
|
||||
picFilter: '',
|
||||
locationFilter: undefined,
|
||||
picFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -143,6 +136,8 @@ const KandangsTable = () => {
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'kandangs-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -151,22 +146,34 @@ const KandangsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
initialValues: {
|
||||
location_id: null,
|
||||
pic_id: null,
|
||||
location: tableFilterState.locationFilter,
|
||||
pic: tableFilterState.picFilter,
|
||||
},
|
||||
validationSchema: KandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('picFilter', values.pic_id || '');
|
||||
updateFilter('locationFilter', values.location || undefined, true);
|
||||
updateFilter('picFilter', values.pic || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('picFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('locationFilter', undefined, true);
|
||||
updateFilter('picFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
location: undefined,
|
||||
pic: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== LOCATION OPTIONS =====
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
@@ -194,43 +201,15 @@ const KandangsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
const handleFilterLocationChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('location', val);
|
||||
};
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterPicChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const pic = val as OptionType | null;
|
||||
const picId = pic?.value ? String(pic.value) : null;
|
||||
|
||||
formik.setFieldValue('pic_id', picId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const picIdValue = useMemo(() => {
|
||||
if (!formik.values.pic_id) return null;
|
||||
return (
|
||||
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.pic_id, picOptions]);
|
||||
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
|
||||
setFieldValue('pic', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -255,17 +234,8 @@ const KandangsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('kandangs-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -475,13 +445,13 @@ const KandangsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
value={formik.values.location}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
@@ -494,7 +464,7 @@ const KandangsTable = () => {
|
||||
label='PIC'
|
||||
placeholder='Pilih PIC'
|
||||
options={picOptions}
|
||||
value={picIdValue}
|
||||
value={formik.values.pic}
|
||||
onChange={handleFilterPicChange}
|
||||
onInputChange={setPicInputValue}
|
||||
isLoading={isLoadingPicOptions}
|
||||
@@ -510,17 +480,14 @@ const KandangsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={!formik.isValid}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const KandangFilterSchema = object().shape({
|
||||
location_id: string().nullable(),
|
||||
pic_id: string().nullable(),
|
||||
export const KandangFilterSchema = Yup.object().shape({
|
||||
location: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
|
||||
pic: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type KandangFilterType = {
|
||||
location_id: string | null;
|
||||
pic_id: string | null;
|
||||
location?: OptionType<string>;
|
||||
pic?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { LocationApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
LocationFilterSchema,
|
||||
LocationFilterType,
|
||||
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const LocationsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
areaFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
areaFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
areaFilter: 'area_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'locations-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -145,19 +139,28 @@ const LocationsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<LocationFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
area: tableFilterState.areaFilter,
|
||||
},
|
||||
validationSchema: LocationFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
@@ -172,24 +175,9 @@ const LocationsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('area', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -212,19 +200,10 @@ const LocationsTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('locations-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -425,13 +404,13 @@ const LocationsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
value={formik.values.area}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
@@ -447,10 +426,7 @@ const LocationsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const LocationFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
export const LocationFilterSchema = Yup.object().shape({
|
||||
area: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type LocationFilterType = {
|
||||
area_id: string | null;
|
||||
area?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const NonstocksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'nonstock-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('nonstocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductCategoryTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-category-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-category-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { formatCurrency } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
ProductFilterSchema,
|
||||
ProductFilterType,
|
||||
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
productCategoryFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
productCategoryFilter: '',
|
||||
productCategoryFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
productCategoryFilter: 'product_category_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,19 +140,32 @@ const ProductsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductFilterType>({
|
||||
initialValues: {
|
||||
product_category_id: null,
|
||||
product_category: tableFilterState.productCategoryFilter,
|
||||
},
|
||||
validationSchema: ProductFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productCategoryFilter', values.product_category_id || '');
|
||||
updateFilter(
|
||||
'productCategoryFilter',
|
||||
values.product_category || undefined,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productCategoryFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('productCategoryFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
product_category: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PRODUCT CATEGORY OPTIONS =====
|
||||
const {
|
||||
setInputValue: setProductCategoryInputValue,
|
||||
@@ -173,25 +180,11 @@ const ProductsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const category = val as OptionType | null;
|
||||
const categoryId = category?.value ? String(category.value) : null;
|
||||
|
||||
formik.setFieldValue('product_category_id', categoryId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productCategoryIdValue = useMemo(() => {
|
||||
if (!formik.values.product_category_id) return null;
|
||||
return (
|
||||
productCategoryOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_category_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_category_id, productCategoryOptions]);
|
||||
const handleFilterProductCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('product_category', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -199,10 +192,6 @@ const ProductsTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -220,13 +209,8 @@ const ProductsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -477,13 +461,13 @@ const ProductsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih Kategori Produk'
|
||||
options={productCategoryOptions}
|
||||
value={productCategoryIdValue}
|
||||
value={formik.values.product_category}
|
||||
onChange={handleFilterProductCategoryChange}
|
||||
onInputChange={setProductCategoryInputValue}
|
||||
isLoading={isLoadingProductCategoryOptions}
|
||||
@@ -499,10 +483,7 @@ const ProductsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const ProductFilterSchema = object().shape({
|
||||
product_category_id: string().nullable(),
|
||||
export const ProductFilterSchema = Yup.object().shape({
|
||||
product_category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type ProductFilterType = {
|
||||
product_category_id: string | null;
|
||||
product_category?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
|
||||
pageSize: 'limit',
|
||||
projectCategoryFilter: 'project_category',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'production-standard-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
|
||||
const filterInitialValues = useMemo<ProductionStandardFilterType>(
|
||||
() => ({
|
||||
project_category: tableFilterState.projectCategoryFilter || null,
|
||||
}),
|
||||
[tableFilterState.projectCategoryFilter]
|
||||
);
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductionStandardFilterType>({
|
||||
initialValues: {
|
||||
project_category: null,
|
||||
},
|
||||
initialValues: filterInitialValues,
|
||||
validationSchema: ProductionStandardFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('projectCategoryFilter', values.project_category || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('projectCategoryFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('projectCategoryFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
project_category: null,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
|
||||
const projectCategoryOptions = useMemo(
|
||||
() => [
|
||||
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
import {
|
||||
SupplierFilterSchema,
|
||||
SupplierFilterType,
|
||||
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const SuppliersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
categoryFilter?: OptionType<string>;
|
||||
flagFilter?: string;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
categoryFilter: '',
|
||||
flagFilter: '',
|
||||
categoryFilter: undefined,
|
||||
flagFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
|
||||
categoryFilter: 'category_id',
|
||||
flagFilter: 'flag',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'supplier-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<SupplierFilterType>({
|
||||
initialValues: {
|
||||
category_id: null,
|
||||
flag: false,
|
||||
category: tableFilterState.categoryFilter,
|
||||
flag: tableFilterState.flagFilter === 'EKSPEDISI',
|
||||
},
|
||||
validationSchema: SupplierFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('categoryFilter', values.category_id || '');
|
||||
updateFilter(
|
||||
'flagFilter',
|
||||
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
|
||||
);
|
||||
updateFilter('categoryFilter', values.category || undefined, true);
|
||||
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
|
||||
filterModal.closeModal();
|
||||
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('categoryFilter', '');
|
||||
updateFilter('flagFilter', '');
|
||||
formik.setFieldValue('flag', false);
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('categoryFilter', undefined, true);
|
||||
updateFilter('flagFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
category: undefined,
|
||||
flag: false,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
|
||||
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const option = val as OptionType | null;
|
||||
const categoryId = option?.value ? String(option.value) : null;
|
||||
|
||||
setFieldValue('category_id', categoryId);
|
||||
},
|
||||
[setFieldValue]
|
||||
);
|
||||
const handleFilterCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const handleFilterFlagChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const categoryIdValue = useMemo(() => {
|
||||
if (!formik.values.category_id) return null;
|
||||
return (
|
||||
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.category_id, categoryOptions]);
|
||||
// const categoryIdValue = useMemo(() => {
|
||||
// if (!formik.values.category_id) return null;
|
||||
// return (
|
||||
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
// null
|
||||
// );
|
||||
// }, [formik.values.category_id, categoryOptions]);
|
||||
|
||||
const flagValue = useMemo(() => {
|
||||
if (formik.values.flag === null) return null;
|
||||
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
|
||||
}
|
||||
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('suppliers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -330,6 +326,11 @@ const SuppliersTable = () => {
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
},
|
||||
{
|
||||
accessorKey: 'bank_name',
|
||||
header: 'Nama Bank',
|
||||
cell: (props) => props.row.original.bank_name || '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: 'Alamat',
|
||||
@@ -491,13 +492,13 @@ const SuppliersTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={categoryIdValue}
|
||||
value={formik.values.category}
|
||||
onChange={handleFilterCategoryChange}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -517,13 +518,9 @@ const SuppliersTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { string, boolean, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const SupplierFilterSchema = object().shape({
|
||||
category_id: string().nullable(),
|
||||
flag: boolean().nullable(),
|
||||
export const SupplierFilterSchema = Yup.object().shape({
|
||||
category: Yup.object({
|
||||
value: Yup.string().required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
flag: Yup.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type SupplierFilterType = {
|
||||
category_id: string | null;
|
||||
category?: OptionType<string>;
|
||||
flag: boolean | null;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,9 @@ export const SupplierFormSchema = Yup.object({
|
||||
npwp: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
|
||||
.required('Nomor NPWP wajib diisi!'),
|
||||
bank_name: Yup.string()
|
||||
.min(3, 'Nama bank minimal 3 karakter!')
|
||||
.required('Nama bank wajib diisi!'),
|
||||
account_number: Yup.string()
|
||||
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
|
||||
.required('Nomor rekening wajib diisi!'),
|
||||
|
||||
@@ -122,6 +122,7 @@ const SupplierForm = ({
|
||||
email: initialValues?.email ?? '',
|
||||
address: initialValues?.address ?? '',
|
||||
npwp: initialValues?.npwp ?? '',
|
||||
bank_name: initialValues?.bank_name ?? '',
|
||||
account_number: initialValues?.account_number ?? '',
|
||||
due_date: initialValues?.due_date ?? 1,
|
||||
};
|
||||
@@ -149,6 +150,7 @@ const SupplierForm = ({
|
||||
email: values.email,
|
||||
address: values.address,
|
||||
npwp: values.npwp,
|
||||
bank_name: values.bank_name,
|
||||
account_number: values.account_number,
|
||||
due_date: parseInt(values.due_date.toString()),
|
||||
};
|
||||
@@ -368,6 +370,22 @@ const SupplierForm = ({
|
||||
errorMessage={formik.errors.npwp}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nama Bank'
|
||||
name='bank_name'
|
||||
placeholder='Masukkan nama bank supplier'
|
||||
value={formik.values.bank_name}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={
|
||||
formik.touched.bank_name && Boolean(formik.errors.bank_name)
|
||||
}
|
||||
errorMessage={formik.errors.bank_name}
|
||||
readOnly={formType === 'detail'}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label='Nomor Rekening'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom';
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const UomsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const UomsTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'uom-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('uoms-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -146,8 +135,7 @@ const UomsTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
WarehouseFilterSchema,
|
||||
WarehouseFilterType,
|
||||
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const WarehousesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
|
||||
areaFilter: 'area_id',
|
||||
activeProjectFlockFilter: 'active_project_flock',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'warehouses-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<WarehouseFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
area_id: tableFilterState.areaFilter || null,
|
||||
active_project_flock:
|
||||
tableFilterState.activeProjectFlockFilter === 'true',
|
||||
},
|
||||
validationSchema: WarehouseFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area_id || '', true);
|
||||
updateFilter(
|
||||
'activeProjectFlockFilter',
|
||||
values.active_project_flock === true ? 'true' : ''
|
||||
values.active_project_flock === true ? 'true' : '',
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
updateFilter('activeProjectFlockFilter', '');
|
||||
formik.setFieldValue('active_project_flock', false);
|
||||
},
|
||||
});
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', '', true);
|
||||
updateFilter('activeProjectFlockFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filterModal.open) {
|
||||
const activeProjectFlockValue =
|
||||
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
|
||||
setFieldValue('active_project_flock', activeProjectFlockValue);
|
||||
}
|
||||
}, [
|
||||
filterModal.open,
|
||||
tableFilterState.activeProjectFlockFilter,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('warehouses-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import Table from '@/components/Table';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { cn, formatDate } from '@/lib/helper';
|
||||
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
@@ -23,7 +22,6 @@ import { Icon } from '@iconify/react';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import toast from 'react-hot-toast';
|
||||
import useSWR from 'swr';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -45,6 +43,7 @@ import {
|
||||
import Modal from '@/components/Modal';
|
||||
import SelectInputRadio from '@/components/input/SelectInputRadio';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import NumberInput from '@/components/input/NumberInput';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
props,
|
||||
@@ -148,7 +147,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
|
||||
@@ -174,6 +172,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
kandang_id: '',
|
||||
category: '',
|
||||
period: '',
|
||||
area_name: '',
|
||||
location_name: '',
|
||||
kandang_name: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -185,7 +186,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
category: 'category',
|
||||
period: 'period',
|
||||
},
|
||||
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
|
||||
persist: true,
|
||||
storeName: 'project-flock-table',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ===== State =====
|
||||
@@ -206,8 +211,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isApproveLoading, setIsApproveLoading] = useState(false);
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
|
||||
const {
|
||||
isChickinApproveModalOpen,
|
||||
isChickinApproveLoading,
|
||||
@@ -257,6 +261,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
updateFilter('kandang_id', values.kandang_id || '');
|
||||
updateFilter('category', values.category || '');
|
||||
updateFilter('period', values.period || '');
|
||||
updateFilter(
|
||||
'area_name',
|
||||
areaValue?.label ? String(areaValue.label) : ''
|
||||
);
|
||||
updateFilter(
|
||||
'location_name',
|
||||
locationValue?.label ? String(locationValue.label) : ''
|
||||
);
|
||||
updateFilter(
|
||||
'kandang_name',
|
||||
kandangValue?.label ? String(kandangValue.label) : ''
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
@@ -266,6 +282,9 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
updateFilter('kandang_id', '');
|
||||
updateFilter('category', '');
|
||||
updateFilter('period', '');
|
||||
updateFilter('area_name', '');
|
||||
updateFilter('location_name', '');
|
||||
updateFilter('kandang_name', '');
|
||||
setFilterAreaId(undefined);
|
||||
setFilterLocationId(undefined);
|
||||
filterModal.closeModal();
|
||||
@@ -307,40 +326,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => [
|
||||
{ value: '1', label: 'Periode 1' },
|
||||
{ value: '2', label: 'Periode 2' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
const found = areaOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.area_id
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.area_name) {
|
||||
return {
|
||||
value: formik.values.area_id,
|
||||
label: tableFilterState.area_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [formik.values.area_id, areaOptions, tableFilterState.area_name]);
|
||||
|
||||
const locationValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
const found = locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.location_name) {
|
||||
return {
|
||||
value: formik.values.location_id,
|
||||
label: tableFilterState.location_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
formik.values.location_id,
|
||||
locationOptions,
|
||||
tableFilterState.location_name,
|
||||
]);
|
||||
|
||||
const kandangValue = useMemo(() => {
|
||||
if (!formik.values.kandang_id) return null;
|
||||
return (
|
||||
kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
) || null
|
||||
const found = kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
);
|
||||
}, [formik.values.kandang_id, kandangOptions]);
|
||||
if (found) return found;
|
||||
if (tableFilterState.kandang_name) {
|
||||
return {
|
||||
value: formik.values.kandang_id,
|
||||
label: tableFilterState.kandang_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [formik.values.kandang_id, kandangOptions, tableFilterState.kandang_name]);
|
||||
|
||||
const categoryValue = useMemo(() => {
|
||||
if (!formik.values.category) return null;
|
||||
@@ -350,13 +384,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
);
|
||||
}, [formik.values.category, categoryOptions]);
|
||||
|
||||
const periodValue = useMemo(() => {
|
||||
if (!formik.values.period) return null;
|
||||
return (
|
||||
periodOptions.find((opt) => opt.value === formik.values.period) || null
|
||||
);
|
||||
}, [formik.values.period, periodOptions]);
|
||||
|
||||
// ===== FILTER DEPENDENCY HANDLERS =====
|
||||
const handleFilterAreaChange = (area: OptionType | null) => {
|
||||
const areaId = area?.value ? String(area.value) : undefined;
|
||||
@@ -425,18 +452,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
setIsDeleteLoading(false);
|
||||
setRowSelection({});
|
||||
};
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('project-flock-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmApprovalHandler = async (
|
||||
notes: string,
|
||||
approvalAction: 'APPROVED' | 'REJECTED'
|
||||
@@ -554,6 +574,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
price: budget.price,
|
||||
total_price: budget.qty * budget.price,
|
||||
})) || [],
|
||||
periode: createdProjectFlock.period ?? '-',
|
||||
} as ProjectFlockFormValues;
|
||||
}, [createdProjectFlock]);
|
||||
|
||||
@@ -776,14 +797,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const exportToExcelHandler = async () => {
|
||||
setIsLoadingExportingToExcel(true);
|
||||
|
||||
toast.error('Not implemented yet!');
|
||||
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const bulkApproveClickHandler = () => {
|
||||
setApprovalAction('APPROVED');
|
||||
confirmModal.openModal();
|
||||
@@ -972,55 +985,17 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize', 'search']}
|
||||
excludeFields={[
|
||||
'page',
|
||||
'pageSize',
|
||||
'search',
|
||||
'area_name',
|
||||
'location_name',
|
||||
'kandang_name',
|
||||
]}
|
||||
onClick={handleFilterModalOpen}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcelHandler}
|
||||
isLoading={isLoadingExportingToExcel}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1349,18 +1324,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
|
||||
isClearable={true}
|
||||
/>
|
||||
|
||||
<SelectInputRadio
|
||||
<NumberInput
|
||||
label='Periode'
|
||||
placeholder='Pilih Periode'
|
||||
options={periodOptions}
|
||||
value={periodValue}
|
||||
onChange={(val) => {
|
||||
if (!Array.isArray(val)) {
|
||||
formik.setFieldValue('period', val?.value || null);
|
||||
}
|
||||
}}
|
||||
name='period'
|
||||
placeholder='Masukkan Periode'
|
||||
value={formik.values.period ?? ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
isClearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ type ProjectFlockFormSchemaType = {
|
||||
label: string;
|
||||
} | null;
|
||||
location_id: number;
|
||||
periode: number | string;
|
||||
kandang_ids: number[];
|
||||
project_budgets: ProjectFlockBudgetsSchemaType[];
|
||||
};
|
||||
@@ -109,6 +110,12 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
|
||||
.min(1, 'Lokasi wajib diisi!')
|
||||
.required('Lokasi wajib diisi!'),
|
||||
|
||||
// Period
|
||||
periode: Yup.number()
|
||||
.typeError('Periode harus berupa angka!')
|
||||
.min(1, 'Periode minimal 1!')
|
||||
.required('Periode wajib diisi!'),
|
||||
|
||||
kandang_ids: Yup.array()
|
||||
.of(Yup.number().required('Kandang tidak valid!'))
|
||||
.min(1, 'Minimal harus ada 1 kandang!')
|
||||
|
||||
@@ -152,6 +152,10 @@ export const ProjectFlockFormConfirmationTable = ({
|
||||
label: 'Standar Produksi',
|
||||
value: projectFlockForm?.production_standard?.label ?? '-',
|
||||
},
|
||||
{
|
||||
label: 'Periode',
|
||||
value: projectFlockForm?.periode ?? '-',
|
||||
},
|
||||
{
|
||||
label: 'Informasi Kandang',
|
||||
value: '',
|
||||
@@ -261,7 +265,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingFlocks,
|
||||
options: optionsFlock,
|
||||
loadMore: loadMoreFlock,
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
location_id: selectedLocation,
|
||||
area_id: selectedArea,
|
||||
@@ -279,7 +283,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingLocations,
|
||||
setInputValue: setInputValueLocation,
|
||||
loadMore: loadMoreLocation,
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id:
|
||||
selectedArea != ''
|
||||
? selectedArea
|
||||
@@ -291,7 +295,7 @@ const ProjectFlockForm = ({
|
||||
isLoadingOptions: isLoadingProductionStandards,
|
||||
setInputValue: setInputValueProductionStandard,
|
||||
loadMore: loadMoreProductionStandard,
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
|
||||
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
|
||||
project_category: selectedCategory,
|
||||
});
|
||||
|
||||
@@ -307,7 +311,7 @@ const ProjectFlockForm = ({
|
||||
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
|
||||
|
||||
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
|
||||
`${selectedFlock?.toString()}/periods`,
|
||||
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
|
||||
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
|
||||
);
|
||||
|
||||
@@ -529,6 +533,7 @@ const ProjectFlockForm = ({
|
||||
kandang_ids: initialValues?.kandangs?.map(
|
||||
(k: Kandang) => k.id
|
||||
) as number[],
|
||||
periode: initialValues?.period ?? '',
|
||||
project_budgets: initialValues?.project_budgets?.map((budget) => {
|
||||
return {
|
||||
nonstock: {
|
||||
@@ -568,6 +573,7 @@ const ProjectFlockForm = ({
|
||||
category: values.category as string,
|
||||
production_standard_id: values.production_standard_id as number,
|
||||
location_id: values.location_id as number,
|
||||
periode: parseInt(values.periode as unknown as string),
|
||||
kandang_ids: values.kandang_ids as number[],
|
||||
project_budgets: values.project_budgets.flatMap((budget) => {
|
||||
return {
|
||||
@@ -793,6 +799,7 @@ const ProjectFlockForm = ({
|
||||
formik.values.kandang_ids?.includes(kandang.id)
|
||||
)?.period
|
||||
: undefined;
|
||||
|
||||
const inputPeriod =
|
||||
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
|
||||
|
||||
@@ -1022,12 +1029,18 @@ const ProjectFlockForm = ({
|
||||
isDisabled={formType != 'add'}
|
||||
/>
|
||||
<NumberInput
|
||||
name='period'
|
||||
name='periode'
|
||||
label='Periode'
|
||||
disabled
|
||||
readOnly
|
||||
placeholder='Periode Flock'
|
||||
value={selectedLocation ? inputPeriod : ''}
|
||||
value={formik.values.periode}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
allowNegative={false}
|
||||
decimalScale={0}
|
||||
isError={
|
||||
formik.touched.periode && Boolean(formik.errors.periode)
|
||||
}
|
||||
errorMessage={formik.errors.periode as string}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
@@ -18,6 +12,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import SelectInput, { useSelect } from '@/components/input/SelectInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -39,13 +34,11 @@ import Table from '@/components/Table';
|
||||
import { type Recording } from '@/types/api/production/recording';
|
||||
import { getRecordingRestriction } from './recording-utils';
|
||||
import { RecordingApi } from '@/services/api/production';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import toast from 'react-hot-toast';
|
||||
import StatusBadge from '@/components/helper/StatusBadge';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Color } from '@/types/theme';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
@@ -75,6 +68,26 @@ const getStatusBadgeColor = (status: string): Color => {
|
||||
return statusBadgeColorMap[normalizedStatus] || 'neutral';
|
||||
};
|
||||
|
||||
const isRecordingApproved = (recording: Recording): boolean => {
|
||||
return (
|
||||
recording.approval?.action === 'APPROVED' &&
|
||||
recording.approval?.step_name === 'Disetujui'
|
||||
);
|
||||
};
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const recordingApprovalStatusOptions: OptionType<string>[] = [
|
||||
{ value: 'CREATED', label: 'Pengajuan' },
|
||||
{ value: 'UPDATED', label: 'Diperbarui' },
|
||||
{ value: 'APPROVED', label: 'Disetujui' },
|
||||
{ value: 'REJECTED', label: 'Ditolak' },
|
||||
];
|
||||
|
||||
const projectFlockCategoryOptions: OptionType<string>[] = [
|
||||
{ value: 'GROWING', label: 'Growing' },
|
||||
{ value: 'LAYING', label: 'Laying' },
|
||||
];
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
props,
|
||||
@@ -266,80 +279,111 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const RecordingTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
areaFilter: OptionType<number> | null;
|
||||
locationFilter: OptionType<number> | null;
|
||||
projectFlockFilter: OptionType<number> | null;
|
||||
kandangFilter: OptionType<number> | null;
|
||||
projectFlockKandangFilter: number | null;
|
||||
approvalStatusFilter: OptionType<string> | null;
|
||||
projectFlockCategoryFilter: OptionType<string> | null;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
locationFilter: '',
|
||||
kandangFilter: '',
|
||||
projectFlockKandangFilter: '',
|
||||
areaFilter: null,
|
||||
locationFilter: null,
|
||||
projectFlockFilter: null,
|
||||
kandangFilter: null,
|
||||
projectFlockKandangFilter: null,
|
||||
approvalStatusFilter: null,
|
||||
projectFlockCategoryFilter: null,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
search: 'search',
|
||||
areaFilter: 'area_id',
|
||||
locationFilter: 'location_id',
|
||||
projectFlockFilter: 'project_flock_id',
|
||||
kandangFilter: 'kandang_id',
|
||||
projectFlockKandangFilter: 'project_flock_kandang_id',
|
||||
approvalStatusFilter: 'approval_status',
|
||||
projectFlockCategoryFilter: 'project_flock_category',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
persist: true,
|
||||
storeName: 'recording-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FILTER STATE =====
|
||||
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
|
||||
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
|
||||
const [filterProjectFlock, setFilterProjectFlock] =
|
||||
useState<OptionType | null>(null);
|
||||
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
|
||||
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
|
||||
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
|
||||
useState<string>('');
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<RecordingFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
kandang_id: null,
|
||||
project_flock_kandang_id: null,
|
||||
area_id: tableFilterState.areaFilter,
|
||||
location_id: tableFilterState.locationFilter,
|
||||
project_flock_id: tableFilterState.projectFlockFilter,
|
||||
kandang_id: tableFilterState.kandangFilter,
|
||||
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
|
||||
approval_status: tableFilterState.approvalStatusFilter,
|
||||
project_flock_category: tableFilterState.projectFlockCategoryFilter,
|
||||
},
|
||||
validationSchema: RecordingFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('kandangFilter', values.kandang_id || '');
|
||||
updateFilter('areaFilter', values.area_id, true);
|
||||
updateFilter('locationFilter', values.location_id, true);
|
||||
updateFilter('projectFlockFilter', values.project_flock_id, true);
|
||||
updateFilter('kandangFilter', values.kandang_id, true);
|
||||
updateFilter(
|
||||
'projectFlockKandangFilter',
|
||||
values.project_flock_kandang_id || ''
|
||||
values.project_flock_kandang_id,
|
||||
true
|
||||
);
|
||||
updateFilter('approvalStatusFilter', values.approval_status, true);
|
||||
updateFilter(
|
||||
'projectFlockCategoryFilter',
|
||||
values.project_flock_category,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('kandangFilter', '');
|
||||
updateFilter('projectFlockKandangFilter', '');
|
||||
},
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', null, true);
|
||||
updateFilter('locationFilter', null, true);
|
||||
updateFilter('projectFlockFilter', null, true);
|
||||
updateFilter('kandangFilter', null, true);
|
||||
updateFilter('projectFlockKandangFilter', null, true);
|
||||
updateFilter('approvalStatusFilter', null, true);
|
||||
updateFilter('projectFlockCategoryFilter', null, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
project_flock_id: null,
|
||||
kandang_id: null,
|
||||
project_flock_kandang_id: null,
|
||||
approval_status: null,
|
||||
project_flock_category: null,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { project_flock_id, kandang_id } = formik.values;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const selectedRowIds = Object.keys(rowSelection).map((item) =>
|
||||
@@ -355,10 +399,14 @@ const RecordingTable = () => {
|
||||
|
||||
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
|
||||
useState(false);
|
||||
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
|
||||
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
|
||||
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
|
||||
|
||||
const singleDeleteModal = useModal();
|
||||
const approveModal = useModal();
|
||||
const rejectModal = useModal();
|
||||
const exportProgressInputModal = useModal();
|
||||
|
||||
const {
|
||||
data: recordings,
|
||||
@@ -370,13 +418,6 @@ const RecordingTable = () => {
|
||||
);
|
||||
|
||||
// ===== LOCATION, AREA, KANDANG OPTIONS =====
|
||||
const locationParams = useMemo(() => {
|
||||
if (filterLocationAreaId) {
|
||||
return { area_id: filterLocationAreaId };
|
||||
}
|
||||
return undefined;
|
||||
}, [filterLocationAreaId]);
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
@@ -387,7 +428,9 @@ const RecordingTable = () => {
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
locationParams
|
||||
{
|
||||
area_id: String(formik.values.area_id?.value),
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -402,13 +445,6 @@ const RecordingTable = () => {
|
||||
'search'
|
||||
);
|
||||
|
||||
const projectFlockParams = useMemo(() => {
|
||||
if (filterProjectFlockLocationId) {
|
||||
return { location_id: filterProjectFlockLocationId };
|
||||
}
|
||||
return undefined;
|
||||
}, [filterProjectFlockLocationId]);
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
@@ -420,34 +456,41 @@ const RecordingTable = () => {
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
projectFlockParams
|
||||
{
|
||||
location_id: String(formik.values.location_id?.value),
|
||||
}
|
||||
);
|
||||
|
||||
const kandangOptions = useMemo(() => {
|
||||
if (!filterProjectFlock || !projectFlocksRawData) return [];
|
||||
if (!project_flock_id || !projectFlocksRawData) return [];
|
||||
if (!isResponseSuccess(projectFlocksRawData)) return [];
|
||||
|
||||
const data = projectFlocksRawData.data as ProjectFlock[];
|
||||
const selectedProjectFlockData = data.find(
|
||||
(pf) => pf.id === filterProjectFlock.value
|
||||
const selectedProjectFlockData = data.find((pf) =>
|
||||
pf.id === formik.values.project_flock_id?.value
|
||||
? Number(formik.values.project_flock_id.value)
|
||||
: 0
|
||||
);
|
||||
|
||||
if (!selectedProjectFlockData?.kandangs) return [];
|
||||
|
||||
return selectedProjectFlockData.kandangs.map((k) => ({
|
||||
value: k.id,
|
||||
label: k.name || '',
|
||||
}));
|
||||
}, [filterProjectFlock, projectFlocksRawData]);
|
||||
}, [project_flock_id, projectFlocksRawData]);
|
||||
|
||||
// ===== PROJECT FLOCK KANDANG LOOKUP =====
|
||||
const projectFlockKandangLookupUrl = useMemo(() => {
|
||||
if (!filterProjectFlock || !filterKandang) return null;
|
||||
if (!project_flock_id?.value || !kandang_id?.value) return null;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
project_flock_id: filterProjectFlock.value.toString(),
|
||||
kandang_id: filterKandang.value.toString(),
|
||||
project_flock_id: project_flock_id.value.toString(),
|
||||
kandang_id: kandang_id.value.toString(),
|
||||
});
|
||||
|
||||
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
|
||||
}, [filterProjectFlock, filterKandang]);
|
||||
}, [project_flock_id, kandang_id]);
|
||||
|
||||
const { data: projectFlockKandangLookupData } = useSWR(
|
||||
projectFlockKandangLookupUrl,
|
||||
@@ -469,118 +512,45 @@ const RecordingTable = () => {
|
||||
? projectFlockKandangLookupData.data
|
||||
: undefined;
|
||||
|
||||
const formikRef = useRef(formik);
|
||||
|
||||
useEffect(() => {
|
||||
formikRef.current = formik;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (projectFlockKandangLookup?.id) {
|
||||
const pfkId = String(projectFlockKandangLookup.id);
|
||||
setFilterProjectFlockKandangId(projectFlockKandangLookup.id);
|
||||
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
|
||||
formik.setFieldValue('project_flock_kandang_id', pfkId);
|
||||
} else {
|
||||
setFilterProjectFlockKandangId(undefined);
|
||||
formikRef.current.setFieldValue('project_flock_kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
}
|
||||
}, [projectFlockKandangLookup]);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('area_id', val);
|
||||
formik.setFieldValue('location_id', null);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
formik.setFieldValue('location_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
const handleFilterLocationChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('location_id', val);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
setFilterArea(area);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterLocationAreaId(areaId || '');
|
||||
setFilterProjectFlockLocationId('');
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
const handleFilterProjectFlockChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('project_flock_id', val);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterLocation(location);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterProjectFlockLocationId(locationId || '');
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterProjectFlockChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const projectFlock = val as OptionType | null;
|
||||
|
||||
formik.setFieldValue('kandang_id', null);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterProjectFlock(projectFlock);
|
||||
setFilterKandang(null);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterKandangChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const kandang = val as OptionType | null;
|
||||
const kandangId = kandang?.value ? String(kandang.value) : null;
|
||||
|
||||
formik.setFieldValue('kandang_id', kandangId);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
|
||||
setFilterKandang(kandang);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const projectFlockIdValue = useMemo(() => {
|
||||
if (!filterProjectFlock) return null;
|
||||
return filterProjectFlock;
|
||||
}, [filterProjectFlock]);
|
||||
|
||||
const kandangIdValue = useMemo(() => {
|
||||
if (!formik.values.kandang_id) return null;
|
||||
return (
|
||||
kandangOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.kandang_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.kandang_id, kandangOptions]);
|
||||
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('kandang_id', val);
|
||||
formik.setFieldValue('project_flock_kandang_id', null);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -588,25 +558,9 @@ const RecordingTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const isRecordingApproved = useCallback((recording: Recording): boolean => {
|
||||
return (
|
||||
recording.approval?.action === 'APPROVED' &&
|
||||
recording.approval?.step_name === 'Disetujui'
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('recording-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value);
|
||||
setSearchValue(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[updateFilter, setSearchValue, setPage]
|
||||
);
|
||||
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const singleDeleteHandler = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
@@ -698,6 +652,60 @@ const RecordingTable = () => {
|
||||
setIsLoadingExportingToExcel(false);
|
||||
};
|
||||
|
||||
const resetExportProgressForm = useCallback(() => {
|
||||
setExportProgressStartDate('');
|
||||
setExportProgressEndDate('');
|
||||
}, []);
|
||||
|
||||
const exportProgressStartDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressStartDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressEndDateChangeHandler = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setExportProgressEndDate(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportProgressInputToExcelClickHandler = useCallback(() => {
|
||||
resetExportProgressForm();
|
||||
exportProgressInputModal.openModal();
|
||||
}, [exportProgressInputModal, resetExportProgressForm]);
|
||||
|
||||
const submitExportProgressInputHandler = useCallback(async () => {
|
||||
if (!exportProgressStartDate || !exportProgressEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportProgressLoading(true);
|
||||
|
||||
try {
|
||||
await RecordingApi.exportInputProgressToExcel(
|
||||
exportProgressStartDate,
|
||||
exportProgressEndDate
|
||||
);
|
||||
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
toast.success('Ekspor berhasil');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor input progress')
|
||||
);
|
||||
} finally {
|
||||
setIsExportProgressLoading(false);
|
||||
}
|
||||
}, [
|
||||
exportProgressEndDate,
|
||||
exportProgressInputModal,
|
||||
exportProgressStartDate,
|
||||
resetExportProgressForm,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResponseSuccess(recordings) && recordings.data) {
|
||||
const newSelection: Record<string, boolean> = {};
|
||||
@@ -858,7 +866,8 @@ const RecordingTable = () => {
|
||||
<>
|
||||
<span>
|
||||
{props.row.original.day} (Minggu ke-
|
||||
{props.row.original.project_flock.production_standart.week})
|
||||
{props.row.original.week} hari ke-
|
||||
{props.row.original.excess_days})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -1104,7 +1113,7 @@ const RecordingTable = () => {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
? `${value.toFixed(2)} butir`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
@@ -1120,7 +1129,7 @@ const RecordingTable = () => {
|
||||
return (
|
||||
<div className='text-center text-gray-600'>
|
||||
{value !== null && value !== undefined
|
||||
? `${value.toFixed(2)}%`
|
||||
? `${value.toFixed(2)} btr`
|
||||
: '-'}
|
||||
</div>
|
||||
);
|
||||
@@ -1368,6 +1377,16 @@ const RecordingTable = () => {
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Export to Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1446,13 +1465,13 @@ const RecordingTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
value={formik.values.area_id}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
@@ -1465,13 +1484,13 @@ const RecordingTable = () => {
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
value={formik.values.location_id}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isDisabled={!filterArea}
|
||||
isDisabled={!formik.values.area_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
@@ -1479,13 +1498,13 @@ const RecordingTable = () => {
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={projectFlockIdValue}
|
||||
value={formik.values.project_flock_id}
|
||||
onChange={handleFilterProjectFlockChange}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
isLoading={isLoadingProjectFlocks}
|
||||
isClearable
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isDisabled={!filterLocation}
|
||||
isDisabled={!formik.values.location_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
@@ -1493,11 +1512,35 @@ const RecordingTable = () => {
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
options={kandangOptions}
|
||||
value={kandangIdValue}
|
||||
value={formik.values.kandang_id}
|
||||
onChange={handleFilterKandangChange}
|
||||
isLoading={!filterProjectFlock}
|
||||
isLoading={!formik.values.project_flock_id?.value}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock_id?.value}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={projectFlockCategoryOptions}
|
||||
value={formik.values.project_flock_category}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('project_flock_category', val);
|
||||
}}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Status Approval'
|
||||
placeholder='Pilih Status Approval'
|
||||
options={recordingApprovalStatusOptions}
|
||||
value={formik.values.approval_status}
|
||||
onChange={(val) => {
|
||||
formik.setFieldValue('approval_status', val);
|
||||
}}
|
||||
isClearable
|
||||
isDisabled={!filterProjectFlock}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1505,30 +1548,16 @@ const RecordingTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
setFilterArea(null);
|
||||
setFilterLocation(null);
|
||||
setFilterProjectFlock(null);
|
||||
setFilterKandang(null);
|
||||
setFilterLocationAreaId('');
|
||||
setFilterProjectFlockLocationId('');
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={
|
||||
!formik.isValid ||
|
||||
formik.isSubmitting ||
|
||||
!formik.values.kandang_id
|
||||
}
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
@@ -1551,6 +1580,76 @@ const RecordingTable = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
ref={exportProgressInputModal.ref}
|
||||
className={{
|
||||
modalBox: 'max-w-lg rounded-lg p-0',
|
||||
}}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
|
||||
<h4 className='text-sm font-semibold text-base-content'>
|
||||
Ekspor Input Progress
|
||||
</h4>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='p-1'
|
||||
>
|
||||
<Icon icon='mdi:close' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
<DateInput
|
||||
name='export_progress_start_date'
|
||||
label='Tanggal Mulai'
|
||||
value={exportProgressStartDate}
|
||||
onChange={exportProgressStartDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
name='export_progress_end_date'
|
||||
label='Tanggal Selesai'
|
||||
value={exportProgressEndDate}
|
||||
onChange={exportProgressEndDateChangeHandler}
|
||||
isNestedModal
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
onClick={() => {
|
||||
exportProgressInputModal.closeModal();
|
||||
resetExportProgressForm();
|
||||
}}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color='success'
|
||||
onClick={submitExportProgressInputHandler}
|
||||
isLoading={isExportProgressLoading}
|
||||
disabled={!exportProgressStartDate || !exportProgressEndDate}
|
||||
className='px-3 py-2.5'
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationModalWithNotes
|
||||
ref={approveModal.ref}
|
||||
type='success'
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const RecordingFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
location_id: string().nullable(),
|
||||
kandang_id: string().nullable(),
|
||||
project_flock_kandang_id: string().nullable(),
|
||||
export const RecordingFilterSchema = Yup.object().shape({
|
||||
area_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
location_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
kandang_id: Yup.object({
|
||||
value: Yup.number().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_kandang_id: Yup.number().nullable(),
|
||||
approval_status: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
project_flock_category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type RecordingFilterType = {
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
kandang_id: string | null;
|
||||
project_flock_kandang_id: string | null;
|
||||
area_id: OptionType<number> | null;
|
||||
location_id: OptionType<number> | null;
|
||||
project_flock_id: OptionType<number> | null;
|
||||
kandang_id: OptionType<number> | null;
|
||||
project_flock_kandang_id: number | null;
|
||||
approval_status: OptionType<string> | null;
|
||||
project_flock_category: OptionType<string> | null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
CreateGrowingRecordingPayload,
|
||||
CreateLayingRecordingPayload,
|
||||
CreateEggPayload,
|
||||
RecordingStock,
|
||||
} from '@/types/api/production/recording';
|
||||
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
|
||||
|
||||
type RecordingGrowingFormSchemaType = {
|
||||
record_date: string;
|
||||
@@ -29,11 +31,19 @@ type RecordingGrowingFormSchemaType = {
|
||||
} | null;
|
||||
project_flock_kandang_id: number;
|
||||
stocks: {
|
||||
product_warehouse_id: number;
|
||||
product_warehouse_id:
|
||||
| {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
| undefined;
|
||||
qty: number | string;
|
||||
}[];
|
||||
depletions: {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
source_product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
}[];
|
||||
@@ -41,34 +51,48 @@ type RecordingGrowingFormSchemaType = {
|
||||
|
||||
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
|
||||
eggs: {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type StockSchema = {
|
||||
product_warehouse_id: number;
|
||||
product_warehouse_id: {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
qty: number | string;
|
||||
};
|
||||
|
||||
export type DepletionSchema = {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
source_product_warehouse_id?: number;
|
||||
qty?: number | string;
|
||||
};
|
||||
|
||||
export type EggSchema = {
|
||||
product_warehouse_id?: number;
|
||||
product_warehouse_id?: {
|
||||
value: number;
|
||||
label: string;
|
||||
} | null;
|
||||
qty?: number | string;
|
||||
weight?: number | string;
|
||||
};
|
||||
|
||||
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.required('Produk wajib diisi!')
|
||||
.min(1, 'Produk wajib diisi!')
|
||||
.typeError('Produk harus berupa angka!'),
|
||||
.typeError('Produk wajib diisi!'),
|
||||
qty: Yup.number()
|
||||
.required('Jumlah penggunaan wajib diisi!')
|
||||
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
|
||||
@@ -76,9 +100,12 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
|
||||
});
|
||||
|
||||
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.optional()
|
||||
.typeError('Depletions harus berupa angka!'),
|
||||
.nullable(),
|
||||
source_product_warehouse_id: Yup.number()
|
||||
.optional()
|
||||
.typeError('Gudang sumber harus berupa angka!'),
|
||||
@@ -88,9 +115,12 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
|
||||
});
|
||||
|
||||
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
|
||||
product_warehouse_id: Yup.number()
|
||||
product_warehouse_id: Yup.object({
|
||||
value: Yup.number().min(1).required(),
|
||||
label: Yup.string().required(),
|
||||
})
|
||||
.optional()
|
||||
.typeError('Kondisi telur harus berupa angka!'),
|
||||
.nullable(),
|
||||
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
|
||||
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
|
||||
});
|
||||
@@ -248,14 +278,18 @@ export const getRecordingGrowingFormInitialValues = (
|
||||
initialValues?.project_flock?.project_flock_kandang_id ??
|
||||
0,
|
||||
stocks: initialValues?.stocks?.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: stock.product_warehouse_id,
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty:
|
||||
(stock as { qty?: number; usage_amount?: number }).qty ||
|
||||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
|
||||
(stock as RecordingStock).qty ||
|
||||
((stock as RecordingStock).usage_amount || 0) +
|
||||
((stock as RecordingStock).pending_qty || 0) ||
|
||||
'',
|
||||
})) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: undefined,
|
||||
qty: '',
|
||||
},
|
||||
],
|
||||
@@ -263,13 +297,16 @@ export const getRecordingGrowingFormInitialValues = (
|
||||
(
|
||||
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
|
||||
) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: Number(depletion.product_warehouse_id ?? 0),
|
||||
label: getProductWarehouseOptionLabel(depletion.product_warehouse),
|
||||
},
|
||||
source_product_warehouse_id: depletion.source_product_warehouse_id,
|
||||
qty: depletion.qty,
|
||||
})
|
||||
) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: undefined,
|
||||
qty: '',
|
||||
},
|
||||
],
|
||||
@@ -281,12 +318,15 @@ export const getRecordingLayingFormInitialValues = (
|
||||
...getRecordingGrowingFormInitialValues(initialValues),
|
||||
|
||||
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id,
|
||||
product_warehouse_id: {
|
||||
value: Number(egg.product_warehouse_id ?? 0),
|
||||
label: getProductWarehouseOptionLabel(egg.product_warehouse),
|
||||
},
|
||||
qty: egg.qty,
|
||||
weight: egg.weight,
|
||||
})) ?? [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
RecordingApi,
|
||||
ProjectFlockApi,
|
||||
} from '@/services/api/production';
|
||||
import { ProductionStandardApi } from '@/services/api/master-data';
|
||||
import { ProductionStandardApi, ProductApi } from '@/services/api/master-data';
|
||||
import {
|
||||
ProductionStandard,
|
||||
StandardDetails,
|
||||
} from '@/types/api/master-data/production-standard';
|
||||
import { Product } from '@/types/api/master-data/product';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import { SystemSettingsApi } from '@/services/api/system-settings';
|
||||
import { ProductWarehouseApi } from '@/services/api/inventory';
|
||||
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
|
||||
|
||||
@@ -499,6 +501,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
type,
|
||||
]);
|
||||
|
||||
// ===== MIGRATION MODE =====
|
||||
const { data: systemSettingsResponse } = useSWR(
|
||||
SystemSettingsApi.basePath,
|
||||
SystemSettingsApi.getAllFetcher
|
||||
);
|
||||
|
||||
const isMigrationMode = useMemo(() => {
|
||||
if (!isResponseSuccess(systemSettingsResponse)) return false;
|
||||
const setting = systemSettingsResponse.data.find(
|
||||
(s) => s.key === 'allow_negative_pakan_ovk'
|
||||
);
|
||||
return setting?.value === 'true';
|
||||
}, [systemSettingsResponse]);
|
||||
|
||||
// ===== PAYLOAD CREATION HELPERS =====
|
||||
const createGrowingPayload = useCallback(
|
||||
(values: RecordingGrowingFormValues) => {
|
||||
@@ -506,7 +522,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
? values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
|
||||
...(depletion.source_product_warehouse_id && {
|
||||
source_product_warehouse_id:
|
||||
depletion.source_product_warehouse_id,
|
||||
@@ -517,9 +533,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
const stocks = recordingRestriction.canEditStock
|
||||
? (values.stocks ?? [])
|
||||
.filter((s) => s.product_warehouse_id && s.qty)
|
||||
.filter((s) => s.product_warehouse_id?.value && s.qty)
|
||||
.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
// In migration mode, product_warehouse_id field holds product.id;
|
||||
// send it as product_id so the backend auto-creates the warehouse entry.
|
||||
...(isMigrationMode
|
||||
? { product_id: stock.product_warehouse_id?.value }
|
||||
: { product_warehouse_id: stock.product_warehouse_id?.value }),
|
||||
qty: Number(stock.qty) || 0,
|
||||
}))
|
||||
: [];
|
||||
@@ -531,15 +551,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(depletions.length > 0 && { depletions }),
|
||||
};
|
||||
},
|
||||
[recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
|
||||
[
|
||||
isMigrationMode,
|
||||
recordingRestriction.canEditStock,
|
||||
recordingRestriction.canEditDepletion,
|
||||
]
|
||||
);
|
||||
|
||||
const createLayingPayload = useCallback(
|
||||
(values: RecordingLayingFormValues) => {
|
||||
const depletions = values.depletions
|
||||
?.filter((d) => d.product_warehouse_id && d.qty)
|
||||
?.filter((d) => d.product_warehouse_id?.value && d.qty)
|
||||
.map((depletion) => ({
|
||||
product_warehouse_id: depletion.product_warehouse_id!,
|
||||
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0,
|
||||
...(depletion.source_product_warehouse_id && {
|
||||
source_product_warehouse_id: depletion.source_product_warehouse_id,
|
||||
}),
|
||||
@@ -549,7 +573,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const eggs = values.eggs
|
||||
?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
|
||||
.map((egg) => ({
|
||||
product_warehouse_id: egg.product_warehouse_id!,
|
||||
product_warehouse_id: egg.product_warehouse_id?.value ?? 0,
|
||||
qty: Number(egg.qty) || 0,
|
||||
weight:
|
||||
typeof egg.weight === 'number'
|
||||
@@ -559,9 +583,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
const stocks = recordingRestriction.canEditStock
|
||||
? values.stocks
|
||||
.filter((s) => s.product_warehouse_id && s.qty)
|
||||
.filter((s) => s.product_warehouse_id?.value && s.qty)
|
||||
.map((stock) => ({
|
||||
product_warehouse_id: stock.product_warehouse_id,
|
||||
...(isMigrationMode
|
||||
? { product_id: stock.product_warehouse_id?.value }
|
||||
: { product_warehouse_id: stock.product_warehouse_id?.value }),
|
||||
qty: Number(stock.qty) || 0,
|
||||
}))
|
||||
: [];
|
||||
@@ -574,7 +600,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(eggs && eggs.length > 0 && { eggs }),
|
||||
};
|
||||
},
|
||||
[recordingRestriction.canEditStock]
|
||||
[isMigrationMode, recordingRestriction.canEditStock]
|
||||
);
|
||||
|
||||
const isRecordingEditable = useCallback((recording?: Recording) => {
|
||||
@@ -603,11 +629,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// When migration mode ON: fetch all master PAKAN/OVK products (no warehouse entry needed).
|
||||
// When migration mode OFF: fetch from product-warehouses as usual.
|
||||
const {
|
||||
setInputValue: setStockProductInputValue,
|
||||
rawData: stockProducts,
|
||||
isLoadingOptions: isLoadingStockProducts,
|
||||
loadMore: loadMoreStockProducts,
|
||||
rawData: stockProductsPW,
|
||||
isLoadingOptions: isLoadingStockProductsPW,
|
||||
loadMore: loadMoreStockProductsPW,
|
||||
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
|
||||
flags: 'PAKAN,OVK',
|
||||
limit: '100',
|
||||
@@ -616,6 +644,29 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}),
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setStockMasterInputValue,
|
||||
rawData: stockProductsMaster,
|
||||
isLoadingOptions: isLoadingStockProductsMaster,
|
||||
loadMore: loadMoreStockProductsMaster,
|
||||
} = useSelect(
|
||||
isMigrationMode ? ProductApi.basePath : null,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{ flags: 'PAKAN,OVK', limit: '100' }
|
||||
);
|
||||
|
||||
const isLoadingStockProducts = isMigrationMode
|
||||
? isLoadingStockProductsMaster
|
||||
: isLoadingStockProductsPW;
|
||||
const loadMoreStockProducts = isMigrationMode
|
||||
? loadMoreStockProductsMaster
|
||||
: loadMoreStockProductsPW;
|
||||
const setStockInputValue = isMigrationMode
|
||||
? setStockMasterInputValue
|
||||
: setStockProductInputValue;
|
||||
|
||||
const {
|
||||
rawData: depletionProductsData,
|
||||
isLoadingOptions: isLoadingDepletionProducts,
|
||||
@@ -999,9 +1050,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
useEffect(() => {
|
||||
const items: Array<ProductWarehouse | null | undefined> = [];
|
||||
|
||||
if (isResponseSuccess(stockProducts)) {
|
||||
if (!isMigrationMode && isResponseSuccess(stockProductsPW)) {
|
||||
items.push(
|
||||
...((stockProducts.data as unknown as ProductWarehouse[]) ?? [])
|
||||
...((stockProductsPW.data as unknown as ProductWarehouse[]) ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1035,7 +1086,8 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
mergeKnownProductWarehouses(items);
|
||||
}, [
|
||||
stockProducts,
|
||||
isMigrationMode,
|
||||
stockProductsPW,
|
||||
depletionProductsData,
|
||||
eggProductsData,
|
||||
initialValues,
|
||||
@@ -1066,9 +1118,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
);
|
||||
|
||||
const unifiedStockProducts = useMemo(() => {
|
||||
const options = isResponseSuccess(stockProducts)
|
||||
if (isMigrationMode) {
|
||||
// In migration mode, show all master PAKAN/OVK products (no warehouse context).
|
||||
// value = product.id; submission will send product_id to the backend.
|
||||
const options: OptionType[] = isResponseSuccess(stockProductsMaster)
|
||||
? (stockProductsMaster.data as unknown as Product[])
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
: [];
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = isResponseSuccess(stockProductsPW)
|
||||
? buildProductWarehouseOptions(
|
||||
stockProducts.data as unknown as ProductWarehouse[]
|
||||
stockProductsPW.data as unknown as ProductWarehouse[]
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -1085,7 +1148,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
|
||||
return options;
|
||||
}, [
|
||||
stockProducts,
|
||||
isMigrationMode,
|
||||
stockProductsMaster,
|
||||
stockProductsPW,
|
||||
buildProductWarehouseOptions,
|
||||
initialValues,
|
||||
type,
|
||||
@@ -1204,6 +1269,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
};
|
||||
}
|
||||
|
||||
// In migration mode (edit), the dropdown options use product.id as their value,
|
||||
// but the API returns product_warehouse_id (PW entity ID). Remap so the dropdown
|
||||
// can match the correct option. The product ID is available on the nested
|
||||
// product_warehouse object returned by the API.
|
||||
if (isMigrationMode && type === 'edit' && initialValues?.stocks?.length) {
|
||||
baseValues.stocks = initialValues.stocks.map((stock) => ({
|
||||
product_warehouse_id: {
|
||||
value: Number(
|
||||
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
|
||||
),
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty: stock.usage_amount ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
if (!recordingRestriction.canEditStock) {
|
||||
baseValues.stocks = [];
|
||||
}
|
||||
@@ -1224,6 +1305,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
selectedKandang,
|
||||
recordingRestriction.canEditStock,
|
||||
recordingRestriction.canEditDepletion,
|
||||
isMigrationMode,
|
||||
]);
|
||||
|
||||
const formik = useFormik<
|
||||
@@ -1335,6 +1417,35 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
// SWR timing fix: formik initializes before system-settings load, so isMigrationMode
|
||||
// starts false. When it flips true, formikInitialValues recomputes but enableReinitialize
|
||||
// is false, so formik won't pick it up. Push the corrected stock values once, and only
|
||||
// once — the ref prevents re-firing if something causes isMigrationMode to re-evaluate.
|
||||
const migrationEditMappingApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
type !== 'edit' ||
|
||||
!isMigrationMode ||
|
||||
!initialValues?.stocks?.length ||
|
||||
migrationEditMappingApplied.current
|
||||
)
|
||||
return;
|
||||
migrationEditMappingApplied.current = true;
|
||||
formik.setFieldValue(
|
||||
'stocks',
|
||||
initialValues.stocks.map((stock) => ({
|
||||
product_warehouse_id: {
|
||||
value: Number(
|
||||
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
|
||||
),
|
||||
label: getProductWarehouseOptionLabel(stock.product_warehouse),
|
||||
},
|
||||
qty: stock.usage_amount ?? '',
|
||||
}))
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isMigrationMode]);
|
||||
|
||||
// ===== HELPER FUNCTIONS =====
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
@@ -1351,7 +1462,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
(stockIdx: number) => {
|
||||
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
|
||||
const stock = formik.values.stocks?.[stockIdx];
|
||||
if (!stock || !stock.product_warehouse_id) return null;
|
||||
if (!stock || !stock.product_warehouse_id?.value) return null;
|
||||
return null;
|
||||
},
|
||||
[formik.values.stocks, type]
|
||||
@@ -1361,7 +1472,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
(productWarehouseId: number) => {
|
||||
if ((type === 'edit' || type === 'detail') && initialValues?.stocks) {
|
||||
const existingStock = initialValues.stocks.find(
|
||||
(s) => s.product_warehouse_id === productWarehouseId
|
||||
(s) => Number(s.product_warehouse_id) === Number(productWarehouseId)
|
||||
) as RecordingStock | undefined;
|
||||
if (existingStock) {
|
||||
return {
|
||||
@@ -1381,21 +1492,25 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const getStockUsageAdornment = useCallback(
|
||||
(stockIdx: number) => {
|
||||
const stock = formik.values.stocks?.[stockIdx];
|
||||
if (!stock || !stock.product_warehouse_id) return null;
|
||||
if (!stock || !stock.product_warehouse_id?.value) return null;
|
||||
|
||||
const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail';
|
||||
const availableStock = getAvailableStock(stock.product_warehouse_id);
|
||||
const availableStock = getAvailableStock(
|
||||
stock.product_warehouse_id.value
|
||||
);
|
||||
const requestedUsage = Number(stock.qty) || 0;
|
||||
const remainingStock = availableStock - requestedUsage;
|
||||
const { pendingQty } = getStockPendingInfo(stock.product_warehouse_id);
|
||||
const { pendingQty } = getStockPendingInfo(
|
||||
stock.product_warehouse_id.value
|
||||
);
|
||||
|
||||
if (isDetail) {
|
||||
if (pendingQty > 0) {
|
||||
return (
|
||||
<span className='text-sm text-gray-600 whitespace-nowrap'>
|
||||
(tersedia: {formatNumber(requestedUsage)} | pending:{' '}
|
||||
(tersedia: {formatNumber(availableStock)} | pending:{' '}
|
||||
<span className='text-error'>{formatNumber(pendingQty)}</span> |
|
||||
pakai: {formatNumber(requestedUsage + pendingQty)})
|
||||
pakai: {formatNumber(requestedUsage)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1494,10 +1609,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
s.product_warehouse_id &&
|
||||
s.product_warehouse_id !== 0
|
||||
s.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((s) => s.product_warehouse_id) || [];
|
||||
.map((s) => s.product_warehouse_id?.value) || [];
|
||||
|
||||
return unifiedStockProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1514,10 +1629,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
d.product_warehouse_id &&
|
||||
d.product_warehouse_id !== 0
|
||||
d.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((d) => d.product_warehouse_id) || [];
|
||||
.map((d) => d.product_warehouse_id?.value) || [];
|
||||
|
||||
return depletionProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1534,10 +1649,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
return (
|
||||
idx !== currentIdx &&
|
||||
e.product_warehouse_id &&
|
||||
e.product_warehouse_id !== 0
|
||||
e.product_warehouse_id.value !== 0
|
||||
);
|
||||
})
|
||||
.map((e) => e.product_warehouse_id) || [];
|
||||
.map((e) => e.product_warehouse_id?.value) || [];
|
||||
|
||||
return eggProducts.filter(
|
||||
(opt) => !selectedProductIds.includes(Number(opt.value))
|
||||
@@ -1583,7 +1698,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
isError: touchedField && Boolean(errorField?.[column]),
|
||||
errorMessage:
|
||||
touchedField && errorField?.[column]
|
||||
? (errorField[column] as string)
|
||||
? errorField[column] instanceof Object
|
||||
? (errorField[column] as OptionType)?.label
|
||||
: (errorField[column] as string)
|
||||
: '',
|
||||
};
|
||||
};
|
||||
@@ -1614,14 +1731,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1629,7 +1746,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1678,14 +1795,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1693,7 +1810,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1731,14 +1848,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('stocks', false, false);
|
||||
formik.setFieldValue('stocks', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
formik.setFieldTouched('depletions', false, false);
|
||||
formik.setFieldValue('depletions', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
]);
|
||||
@@ -1746,7 +1863,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
formik.setFieldTouched('eggs', false, false);
|
||||
formik.setFieldValue('eggs', [
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
weight: '',
|
||||
},
|
||||
@@ -1959,7 +2076,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newStocks = [
|
||||
...(formik.values.stocks || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -1991,7 +2108,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newDepletions = [
|
||||
...(formik.values.depletions || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -2025,7 +2142,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
const newEggs = [
|
||||
...((formik.values as RecordingLayingFormValues).eggs || []),
|
||||
{
|
||||
product_warehouse_id: 0,
|
||||
product_warehouse_id: null,
|
||||
qty: '',
|
||||
},
|
||||
];
|
||||
@@ -2068,7 +2185,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
|
||||
const layingValues = formik.values as RecordingLayingFormValues;
|
||||
if (!layingValues.eggs || layingValues.eggs.length === 0) {
|
||||
setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]);
|
||||
setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]);
|
||||
}
|
||||
}
|
||||
}, [isLayingCategory, type, formik.values, setFieldValue]);
|
||||
@@ -2790,20 +2907,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
<td>
|
||||
<SelectInput
|
||||
required
|
||||
key={`stock-product-${idx}-${stock.product_warehouse_id}`}
|
||||
value={
|
||||
unifiedStockProducts.find(
|
||||
(product) =>
|
||||
product.value === stock.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
onInputChange={setStockProductInputValue}
|
||||
key={`stock-product-${idx}-${stock.product_warehouse_id?.value}`}
|
||||
value={stock.product_warehouse_id}
|
||||
onInputChange={setStockInputValue}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`stocks.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableStockProductOptions(idx)}
|
||||
@@ -2839,9 +2951,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
}
|
||||
isClearable={type !== 'detail'}
|
||||
inputPrefix={
|
||||
stock.product_warehouse_id
|
||||
stock.product_warehouse_id?.value
|
||||
? getProductFlagBadgeAdornment(
|
||||
stock.product_warehouse_id
|
||||
stock.product_warehouse_id.value
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@@ -2877,7 +2989,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
inputSuffix={
|
||||
stock.product_warehouse_id
|
||||
? getProductUomSuffix(
|
||||
stock.product_warehouse_id,
|
||||
stock.product_warehouse_id.value,
|
||||
'stock'
|
||||
)
|
||||
: null
|
||||
@@ -3070,19 +3182,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
<td>
|
||||
<SelectInput
|
||||
value={
|
||||
depletionProducts.find(
|
||||
(product) =>
|
||||
product.value ===
|
||||
depletion.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
value={depletion.product_warehouse_id}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`depletions.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableDepletionProductOptions(idx)}
|
||||
@@ -3145,7 +3251,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
inputSuffix={
|
||||
depletion.product_warehouse_id
|
||||
? getProductUomSuffix(
|
||||
depletion.product_warehouse_id,
|
||||
depletion.product_warehouse_id.value,
|
||||
'depletion'
|
||||
)
|
||||
: null
|
||||
@@ -3323,18 +3429,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
)}
|
||||
<td>
|
||||
<SelectInput
|
||||
value={
|
||||
eggProducts.find(
|
||||
(product) =>
|
||||
product.value === egg.product_warehouse_id
|
||||
) || null
|
||||
}
|
||||
value={egg.product_warehouse_id}
|
||||
onChange={(selectedOption) => {
|
||||
const option =
|
||||
selectedOption as OptionType | null;
|
||||
formik.setFieldValue(
|
||||
`eggs.${idx}.product_warehouse_id`,
|
||||
option?.value || 0
|
||||
option
|
||||
);
|
||||
}}
|
||||
options={getAvailableEggProductOptions(idx)}
|
||||
|
||||
+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}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useState, useEffect } from 'react';
|
||||
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -9,31 +9,49 @@ 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 = () => {
|
||||
const closeModalHandler = useCallback(() => {
|
||||
ref.current?.close();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
// ===== DATE ERROR STATE =====
|
||||
const [dateErrorShown, setDateErrorShown] = useState(false);
|
||||
const [hasDateError, setHasDateError] = useState(false);
|
||||
|
||||
// ===== CLEANUP TOAST ON UNMOUNT =====
|
||||
useEffect(() => {
|
||||
@@ -73,32 +91,134 @@ const PurchaseFilterModal = ({
|
||||
'search'
|
||||
);
|
||||
|
||||
const [selectedAreaId, setSelectedAreaId] = useState(
|
||||
initialValues?.area?.value ? String(initialValues.area.value) : ''
|
||||
);
|
||||
const [selectedLocationId, setSelectedLocationId] = useState(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
|
||||
const {
|
||||
setInputValue: setSupplierInputValue,
|
||||
options: supplierOptions,
|
||||
isLoadingOptions: isLoadingSupplierOptions,
|
||||
loadMore: loadMoreSuppliers,
|
||||
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
options: areaOptions,
|
||||
isLoadingOptions: isLoadingAreaOptions,
|
||||
loadMore: loadMoreAreas,
|
||||
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
|
||||
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
options: locationOptions,
|
||||
isLoadingOptions: isLoadingLocationOptions,
|
||||
loadMore: loadMoreLocations,
|
||||
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
|
||||
area_id: selectedAreaId || '',
|
||||
});
|
||||
|
||||
const {
|
||||
setInputValue: setProjectFlockInputValue,
|
||||
options: projectFlockOptions,
|
||||
rawData: projectFlocksRawData,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationId || '',
|
||||
}
|
||||
);
|
||||
|
||||
const formik = useFormik<{
|
||||
poDate: string;
|
||||
category: { label: string; value: number }[];
|
||||
status: { label: string; value: string }[];
|
||||
supplier: OptionType<number> | null;
|
||||
area: OptionType<number> | null;
|
||||
location: OptionType<number> | null;
|
||||
project_flock: OptionType<number> | null;
|
||||
project_flock_kandang: OptionType<number> | null;
|
||||
}>({
|
||||
initialValues: {
|
||||
// enableReinitialize: true,
|
||||
initialValues: initialValues || {
|
||||
poDate: '',
|
||||
category: [],
|
||||
status: [],
|
||||
supplier: null,
|
||||
area: null,
|
||||
location: null,
|
||||
project_flock: null,
|
||||
project_flock_kandang: null,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const formattedValues = {
|
||||
...values,
|
||||
category: values.category.map((item) => String(item.value)),
|
||||
category_labels: values.category,
|
||||
status: values.status.map((item) => String(item.value)),
|
||||
supplier_id: values.supplier?.value,
|
||||
supplier_label: values.supplier?.label,
|
||||
area_id: values.area?.value,
|
||||
area_label: values.area?.label,
|
||||
location_id: values.location?.value,
|
||||
location_label: values.location?.label,
|
||||
project_flock_id: values.project_flock?.value,
|
||||
project_flock_label: values.project_flock?.label,
|
||||
project_flock_kandang_id: values.project_flock_kandang?.value,
|
||||
project_flock_kandang_label: values.project_flock_kandang?.label,
|
||||
};
|
||||
|
||||
onSubmit?.(formattedValues);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: () => {
|
||||
setSelectedAreaId('');
|
||||
setSelectedLocationId('');
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const { resetForm, submitForm } = formik;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAreaId(
|
||||
initialValues?.area?.value ? String(initialValues.area.value) : ''
|
||||
);
|
||||
setSelectedLocationId(
|
||||
initialValues?.location?.value ? String(initialValues.location.value) : ''
|
||||
);
|
||||
}, [initialValues?.area, initialValues?.location]);
|
||||
|
||||
const projectFlockKandangOptions = useMemo(() => {
|
||||
if (
|
||||
!formik.values.project_flock ||
|
||||
!projectFlocksRawData ||
|
||||
!isResponseSuccess(projectFlocksRawData)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedProjectFlock = projectFlocksRawData.data.find(
|
||||
(item) => item.id === formik.values.project_flock?.value
|
||||
);
|
||||
|
||||
return (
|
||||
selectedProjectFlock?.kandangs?.map((item) => ({
|
||||
value: item.project_flock_kandang_id,
|
||||
label: item.name,
|
||||
})) || []
|
||||
);
|
||||
}, [formik.values.project_flock, projectFlocksRawData]);
|
||||
|
||||
const productCategoryChangeHandler = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
@@ -109,6 +229,29 @@ const PurchaseFilterModal = ({
|
||||
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}
|
||||
@@ -118,7 +261,7 @@ const PurchaseFilterModal = ({
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
onReset={formikResetHandler}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
{/* Modal Header */}
|
||||
@@ -132,7 +275,9 @@ const PurchaseFilterModal = ({
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
onClick={() => {
|
||||
closeModalHandler();
|
||||
}}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
@@ -172,6 +317,108 @@ const PurchaseFilterModal = ({
|
||||
value: item.step_name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Vendor'
|
||||
placeholder='Pilih Vendor'
|
||||
value={formik.values.supplier}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'supplier',
|
||||
!Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null
|
||||
)
|
||||
}
|
||||
options={supplierOptions}
|
||||
isLoading={isLoadingSupplierOptions}
|
||||
onInputChange={setSupplierInputValue}
|
||||
onMenuScrollToBottom={loadMoreSuppliers}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
value={formik.values.area}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('area', nextValue);
|
||||
formik.setFieldValue('location', null);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedAreaId(
|
||||
nextValue?.value ? String(nextValue.value) : ''
|
||||
);
|
||||
setSelectedLocationId('');
|
||||
}}
|
||||
options={areaOptions}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isClearable
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
value={formik.values.location}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('location', nextValue);
|
||||
formik.setFieldValue('project_flock', null);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
setSelectedLocationId(
|
||||
nextValue?.value ? String(nextValue.value) : ''
|
||||
);
|
||||
}}
|
||||
options={locationOptions}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isClearable
|
||||
isDisabled={!formik.values.area}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
value={formik.values.project_flock}
|
||||
onChange={(val) => {
|
||||
const nextValue = !Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null;
|
||||
formik.setFieldValue('project_flock', nextValue);
|
||||
formik.setFieldValue('project_flock_kandang', null);
|
||||
}}
|
||||
options={projectFlockOptions}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isClearable
|
||||
isDisabled={!formik.values.location}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Kandang'
|
||||
placeholder='Pilih Kandang'
|
||||
value={formik.values.project_flock_kandang}
|
||||
onChange={(val) =>
|
||||
formik.setFieldValue(
|
||||
'project_flock_kandang',
|
||||
!Array.isArray(val)
|
||||
? (val as OptionType<number> | null)
|
||||
: null
|
||||
)
|
||||
}
|
||||
options={projectFlockKandangOptions}
|
||||
isClearable
|
||||
isDisabled={!formik.values.project_flock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +434,8 @@ const PurchaseFilterModal = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
type='button'
|
||||
onClick={formikSubmitHandler}
|
||||
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
|
||||
>
|
||||
Apply Filter
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
Updater,
|
||||
} from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@iconify/react';
|
||||
import Table from '@/components/Table';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import Button from '@/components/Button';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import ConfirmationModal from '@/components/modal/ConfirmationModal';
|
||||
import PopoverButton from '@/components/popover/PopoverButton';
|
||||
import PopoverContent from '@/components/popover/PopoverContent';
|
||||
@@ -28,17 +25,37 @@ 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, PurchaseFilter } from '@/types/api/purchase/purchase';
|
||||
import { PurchaseApi } from '@/services/api/purchase';
|
||||
import { ExpenseApi } from '@/services/api/expense';
|
||||
import { Expense } from '@/types/api/expense';
|
||||
import { Color } from '@/types/theme';
|
||||
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
|
||||
|
||||
type PurchaseTableFilters = {
|
||||
search: string;
|
||||
sort_by: string;
|
||||
order_by: string;
|
||||
po_date: string;
|
||||
approval_status: string;
|
||||
product_category_id: string;
|
||||
product_category_name: string;
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
area_id: string;
|
||||
area_name: string;
|
||||
location_id: string;
|
||||
location_name: string;
|
||||
project_flock_id: string;
|
||||
project_flock_name: string;
|
||||
project_flock_kandang_id: string;
|
||||
project_flock_kandang_name: string;
|
||||
};
|
||||
|
||||
// ===== STATUS BADGE UTILITIES =====
|
||||
const statusTextMap: Record<string, string> = {
|
||||
@@ -147,42 +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 {
|
||||
@@ -194,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;
|
||||
@@ -239,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>
|
||||
);
|
||||
@@ -269,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;
|
||||
@@ -284,7 +323,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'location.name',
|
||||
accessorKey: 'location',
|
||||
header: 'Lokasi',
|
||||
cell: (props) => props.row.original.location?.name || '-',
|
||||
},
|
||||
@@ -296,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',
|
||||
@@ -306,6 +353,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
{
|
||||
header: 'Aging',
|
||||
enableSorting: false,
|
||||
cell: (props) => {
|
||||
const purchase = props.row.original;
|
||||
if (!purchase.po_date) return '-';
|
||||
@@ -317,6 +365,7 @@ const PurchaseTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status Approval',
|
||||
cell: (props) => {
|
||||
const approval = props.row.original.latest_approval;
|
||||
@@ -361,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) => {
|
||||
@@ -392,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!');
|
||||
}
|
||||
@@ -403,34 +467,191 @@ const PurchaseTable = () => {
|
||||
setIsDeleteLoading(false);
|
||||
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('purchase-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
},
|
||||
[updateFilter, setSearchValue]
|
||||
[updateFilter]
|
||||
);
|
||||
|
||||
const filterSubmitHandler = (values: PurchaseFilter) => {
|
||||
updateFilter('po_date', values.poDate);
|
||||
updateFilter('product_category_id', values.category.join(','));
|
||||
updateFilter('approval_status', values.status.join(','));
|
||||
setFilters({
|
||||
po_date: values.poDate,
|
||||
product_category_id: values.category.join(','),
|
||||
product_category_name:
|
||||
values.category_labels?.map((item) => item.label).join(',') || '',
|
||||
approval_status: values.status.join(','),
|
||||
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
|
||||
supplier_name: values.supplier_label || '',
|
||||
area_id: values.area_id ? String(values.area_id) : '',
|
||||
area_name: values.area_label || '',
|
||||
location_id: values.location_id ? String(values.location_id) : '',
|
||||
location_name: values.location_label || '',
|
||||
project_flock_id: values.project_flock_id
|
||||
? String(values.project_flock_id)
|
||||
: '',
|
||||
project_flock_name: values.project_flock_label || '',
|
||||
project_flock_kandang_id: values.project_flock_kandang_id
|
||||
? String(values.project_flock_kandang_id)
|
||||
: '',
|
||||
project_flock_kandang_name: values.project_flock_kandang_label || '',
|
||||
});
|
||||
};
|
||||
|
||||
const filterResetHandler = () => {
|
||||
updateFilter('po_date', '');
|
||||
updateFilter('product_category_id', '');
|
||||
updateFilter('approval_status', '');
|
||||
setFilters({
|
||||
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 (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
@@ -477,11 +698,73 @@ const PurchaseTable = () => {
|
||||
'search',
|
||||
'filter_by',
|
||||
'sort_by',
|
||||
'order_by',
|
||||
'product_category_name',
|
||||
'supplier_name',
|
||||
'area_name',
|
||||
'location_name',
|
||||
'project_flock_name',
|
||||
'project_flock_kandang_name',
|
||||
]}
|
||||
fieldGroups={[['startDate', 'endDate']]}
|
||||
onClick={filterModal.openModal}
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
align='end'
|
||||
direction='bottom'
|
||||
className={{
|
||||
content:
|
||||
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
variant='outline'
|
||||
color='none'
|
||||
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-1.5'>
|
||||
<Icon
|
||||
icon='heroicons:cloud-arrow-down'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
|
||||
<span>Export</span>
|
||||
|
||||
<div className='w-px self-stretch bg-base-content/10' />
|
||||
|
||||
<Icon
|
||||
icon='heroicons:chevron-down'
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportToExcel}
|
||||
isLoading={isLoadingExportingToExcel}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor ke Excel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={exportProgressInputToExcelClickHandler}
|
||||
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
|
||||
>
|
||||
<Icon icon='heroicons:table-cells' width={20} height={20} />
|
||||
Ekspor Input Progress (Excel)
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -529,7 +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',
|
||||
@@ -543,6 +827,7 @@ const PurchaseTable = () => {
|
||||
|
||||
<PurchaseFilterModal
|
||||
ref={filterModal.ref}
|
||||
initialValues={purchaseFilterInitialValues}
|
||||
onSubmit={filterSubmitHandler}
|
||||
onReset={filterResetHandler}
|
||||
/>
|
||||
@@ -562,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import React from 'react';
|
||||
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
|
||||
import Table from '@/components/Table';
|
||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
type ReportExpenseColumn =
|
||||
| ColumnDef<ReportExpense>
|
||||
type ReportSkeletonColumn<TData extends object> =
|
||||
| ColumnDef<TData>
|
||||
| {
|
||||
header: string;
|
||||
columns: Array<{
|
||||
header: string;
|
||||
accessorKey?: string;
|
||||
cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
|
||||
cell?: (props: { row: { original: TData } }) => React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
const ReportExpenseSkeleton = ({
|
||||
const ReportExpenseSkeleton = <TData extends object>({
|
||||
columns,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
columns: ReportExpenseColumn[];
|
||||
columns: ReportSkeletonColumn<TData>[];
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { RefObject, useEffect, useMemo, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Modal from '@/components/Modal';
|
||||
import Button from '@/components/Button';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import SelectInput, {
|
||||
OptionType,
|
||||
useSelect,
|
||||
} from '@/components/input/SelectInput';
|
||||
|
||||
import { AreaApi, LocationApi } from '@/services/api/master-data';
|
||||
import { ProjectFlockApi } from '@/services/api/production';
|
||||
import { Area } from '@/types/api/master-data/area';
|
||||
import { Location } from '@/types/api/master-data/location';
|
||||
import { ProjectFlock } from '@/types/api/production/project-flock';
|
||||
|
||||
export type ReportDepreciationFilterValues = {
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
project_flock_id: string | null;
|
||||
period: string | null;
|
||||
};
|
||||
|
||||
export const ReportDepreciationFilterSchema = yup.object({
|
||||
area_id: yup.string().nullable(),
|
||||
location_id: yup.string().nullable(),
|
||||
project_flock_id: yup.string().nullable(),
|
||||
period: yup.string().nullable().required('Periode wajib dipilih'),
|
||||
}) as yup.ObjectSchema<ReportDepreciationFilterValues>;
|
||||
|
||||
interface ReportDepreciationFilterModalProps {
|
||||
ref: RefObject<HTMLDialogElement | null>;
|
||||
initialValues?: ReportDepreciationFilterValues;
|
||||
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const defaultInitialValues: ReportDepreciationFilterValues = {
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
project_flock_id: null,
|
||||
period: null,
|
||||
};
|
||||
|
||||
const ReportDepreciationFilterModal = ({
|
||||
ref,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}: ReportDepreciationFilterModalProps) => {
|
||||
const [selectedAreaId, setSelectedAreaId] = useState<string | undefined>(
|
||||
initialValues?.area_id || undefined
|
||||
);
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<
|
||||
string | undefined
|
||||
>(initialValues?.location_id || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAreaId(initialValues?.area_id || undefined);
|
||||
setSelectedLocationId(initialValues?.location_id || undefined);
|
||||
}, [initialValues?.area_id, initialValues?.location_id]);
|
||||
|
||||
const closeModalHandler = () => {
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const {
|
||||
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,
|
||||
isLoadingOptions: isLoadingProjectFlockOptions,
|
||||
loadMore: loadMoreProjectFlocks,
|
||||
} = useSelect<ProjectFlock>(
|
||||
ProjectFlockApi.basePath,
|
||||
'id',
|
||||
'flock_name',
|
||||
'search',
|
||||
{
|
||||
location_id: selectedLocationId || '',
|
||||
}
|
||||
);
|
||||
|
||||
const formik = useFormik<ReportDepreciationFilterValues>({
|
||||
initialValues: initialValues || defaultInitialValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: ReportDepreciationFilterSchema,
|
||||
onSubmit: async (values) => {
|
||||
onSubmit?.(values);
|
||||
closeModalHandler();
|
||||
},
|
||||
onReset: (_) => {
|
||||
onReset?.();
|
||||
closeModalHandler();
|
||||
},
|
||||
});
|
||||
|
||||
const areaValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
|
||||
const locationValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const projectFlockValue = useMemo(() => {
|
||||
if (!formik.values.project_flock_id) return null;
|
||||
return (
|
||||
projectFlockOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.project_flock_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.project_flock_id, projectFlockOptions]);
|
||||
|
||||
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const areaId = val && !Array.isArray(val) ? String(val.value) : null;
|
||||
|
||||
setSelectedAreaId(areaId || undefined);
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
formik.setFieldValue('location_id', null);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
setSelectedLocationId(undefined);
|
||||
};
|
||||
|
||||
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const locationId = val && !Array.isArray(val) ? String(val.value) : null;
|
||||
|
||||
setSelectedLocationId(locationId || undefined);
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
formik.setFieldValue('project_flock_id', null);
|
||||
};
|
||||
|
||||
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
|
||||
const projectFlockId =
|
||||
val && !Array.isArray(val) ? String(val.value) : null;
|
||||
|
||||
formik.setFieldValue('project_flock_id', projectFlockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ref={ref}
|
||||
className={{
|
||||
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
onReset={formik.handleReset}
|
||||
className='w-full flex flex-col'
|
||||
>
|
||||
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
|
||||
<div className='flex items-center gap-2 text-primary'>
|
||||
<Icon icon='heroicons:funnel' width={20} height={20} />
|
||||
<h3 className='text-sm font-medium'>Filter Data</h3>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
color='none'
|
||||
onClick={closeModalHandler}
|
||||
className='p-0 text-base-content/50 hover:text-base-content'
|
||||
>
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaValue}
|
||||
onChange={areaChangeHandler}
|
||||
onInputChange={setAreaInputValue}
|
||||
onMenuScrollToBottom={loadMoreAreas}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationValue}
|
||||
onChange={locationChangeHandler}
|
||||
onInputChange={setLocationInputValue}
|
||||
onMenuScrollToBottom={loadMoreLocations}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label='Project Flock'
|
||||
placeholder='Pilih Project Flock'
|
||||
options={projectFlockOptions}
|
||||
value={projectFlockValue}
|
||||
onChange={projectFlockChangeHandler}
|
||||
onInputChange={setProjectFlockInputValue}
|
||||
onMenuScrollToBottom={loadMoreProjectFlocks}
|
||||
isLoading={isLoadingProjectFlockOptions}
|
||||
isClearable
|
||||
isSearchable={true}
|
||||
className={{ wrapper: 'w-full' }}
|
||||
/>
|
||||
|
||||
<DateInput
|
||||
label='Periode'
|
||||
name='period'
|
||||
placeholder='Pilih Periode'
|
||||
value={formik.values.period || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
isError={formik.touched.period && !!formik.errors.period}
|
||||
errorMessage={formik.errors.period}
|
||||
required
|
||||
isNestedModal
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportDepreciationFilterModal;
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@iconify/react';
|
||||
import Card from '@/components/Card';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import Table from '@/components/Table';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
|
||||
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import { ReportDepreciation } from '@/types/api/report/report-expense';
|
||||
import { DepreciationReportApi } from '@/services/api/report/expense-report';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
|
||||
|
||||
interface ReportDepreciationTabProps {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
reset: resetFilter,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
area_id: '',
|
||||
location_id: '',
|
||||
project_flock_id: '',
|
||||
period: formatDate(Date.now(), 'YYYY-MM-DD'),
|
||||
},
|
||||
paramMap: {
|
||||
pageSize: 'limit',
|
||||
area_id: 'area_id',
|
||||
location_id: 'location_id',
|
||||
project_flock_id: 'project_flock_id',
|
||||
period: 'period',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
|
||||
useSWR(
|
||||
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`,
|
||||
DepreciationReportApi.getAllFetcher
|
||||
);
|
||||
|
||||
const depreciations = isResponseSuccess(depreciationsResponse)
|
||||
? depreciationsResponse.data
|
||||
: [];
|
||||
|
||||
const filterModal = useModal();
|
||||
const { ref: filterModalRef } = filterModal;
|
||||
|
||||
const setTabActions = useTabActionsStore((state) => state.setTabActions);
|
||||
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
|
||||
|
||||
const depreciationKandangColumns: ColumnDef<
|
||||
ReportDepreciation['components']['kandang'][0]
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: 'kandang_name',
|
||||
header: 'Kandang',
|
||||
},
|
||||
{
|
||||
accessorKey: 'house_type',
|
||||
header: 'Tipe Kandang',
|
||||
cell: ({ row }) => row.original.house_type.toUpperCase(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'depreciation_percent',
|
||||
header: 'Persentase Depresiasi',
|
||||
cell: ({ row }) => row.original.depreciation_percent + '%',
|
||||
},
|
||||
{
|
||||
accessorKey: 'depreciation_value',
|
||||
header: 'Nilai Depresiasi',
|
||||
cell: ({ row }) => formatCurrency(row.original.depreciation_value),
|
||||
},
|
||||
{
|
||||
accessorKey: 'depreciation_source',
|
||||
header: 'Asal Depresiasi',
|
||||
cell: ({ row }) => row.original.depreciation_source.toUpperCase(),
|
||||
},
|
||||
{
|
||||
accessorKey: 'cutover_date',
|
||||
header: 'Tanggal Cutover',
|
||||
cell: ({ row }) => formatDate(row.original.cutover_date, 'DD MMM YYYY'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'origin_date',
|
||||
header: 'Tanggal Origin',
|
||||
cell: ({ row }) => formatDate(row.original.origin_date, 'DD MMM YYYY'),
|
||||
},
|
||||
];
|
||||
|
||||
const tabActionsElement = useMemo(
|
||||
() => (
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={tableFilterState}
|
||||
excludeFields={['page', 'pageSize']}
|
||||
onClick={() => filterModal.openModal()}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[tableFilterState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTabActions(tabId, tabActionsElement);
|
||||
}, [setTabActions, tabActionsElement, tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTabActions(tabId);
|
||||
};
|
||||
}, [clearTabActions, tabId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
|
||||
{isLoadingDepreciations && (
|
||||
<div className='w-full flex flex-row justify-center items-center p-4'>
|
||||
<span className='loading loading-spinner loading-xl' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingDepreciations && depreciations.length === 0 && (
|
||||
<ReportExpenseSkeleton
|
||||
columns={depreciationKandangColumns}
|
||||
icon={
|
||||
<Icon
|
||||
icon='heroicons:chart-bar'
|
||||
className='text-white'
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
}
|
||||
title='Data Not Yet Available'
|
||||
subtitle='Please change your filters to get the data.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoadingDepreciations && depreciations.length > 0 && (
|
||||
<>
|
||||
{depreciations.map((depreciationItem, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
title={depreciationItem.farm_name}
|
||||
subtitle={`Period: ${formatDate(depreciationItem.period, 'DD MMM YYYY')} | Depresiasi Efektif: ${formatNumber(depreciationItem.depreciation_percent_effective, 'en-US', 0, 10)}% | Nilai Depresiasi: ${formatCurrency(depreciationItem.depreciation_value)} | Total Pullet Cost: ${formatCurrency(depreciationItem.pullet_cost_day_n_total, 'IDR', 'id-ID', 0, 10)}`}
|
||||
className={{
|
||||
wrapper: 'w-full rounded-lg border-none',
|
||||
body: 'p-0',
|
||||
title:
|
||||
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
|
||||
subtitle:
|
||||
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
|
||||
collapsible: 'rounded-lg',
|
||||
}}
|
||||
variant='bordered'
|
||||
collapsible={true}
|
||||
>
|
||||
<Table
|
||||
data={depreciationItem.components.kandang}
|
||||
columns={depreciationKandangColumns}
|
||||
pageSize={tableFilterState.pageSize}
|
||||
page={
|
||||
isResponseSuccess(depreciationsResponse)
|
||||
? depreciationsResponse?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(depreciationsResponse)
|
||||
? depreciationsResponse?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoadingDepreciations}
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
tableWrapperClassName:
|
||||
'overflow-x-auto rounded-tr-none rounded-tl-none',
|
||||
tableClassName: 'w-full table-auto text-sm',
|
||||
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
|
||||
headerColumnClassName:
|
||||
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
|
||||
bodyRowClassName:
|
||||
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
|
||||
bodyColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
tableFooterClassName:
|
||||
'bg-gray-100 font-semibold border border-gray-200',
|
||||
footerRowClassName: 'border-t-2 border-gray-300',
|
||||
footerColumnClassName:
|
||||
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
|
||||
paginationClassName: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Pagination
|
||||
totalItems={
|
||||
isResponseSuccess(depreciationsResponse)
|
||||
? (depreciationsResponse?.meta?.total_results ?? 0)
|
||||
: 0
|
||||
}
|
||||
itemsPerPage={tableFilterState.pageSize}
|
||||
currentPage={
|
||||
isResponseSuccess(depreciationsResponse)
|
||||
? (depreciationsResponse?.meta?.page ?? 0)
|
||||
: 0
|
||||
}
|
||||
onPrevPage={() => setPage(tableFilterState.page - 1)}
|
||||
onNextPage={() => setPage(tableFilterState.page + 1)}
|
||||
onPageChange={setPage}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReportDepreciationFilterModal
|
||||
ref={filterModalRef}
|
||||
initialValues={tableFilterState}
|
||||
onReset={resetFilter}
|
||||
onSubmit={(values) => {
|
||||
updateFilter('area_id', values.area_id ?? '');
|
||||
updateFilter('location_id', values.location_id ?? '');
|
||||
updateFilter('project_flock_id', values.project_flock_id ?? '');
|
||||
updateFilter(
|
||||
'period',
|
||||
values.period ? formatDate(values.period, 'YYYY-MM-DD') : ''
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportDepreciationTab;
|
||||
@@ -23,8 +23,8 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
|
||||
import Table from '@/components/Table';
|
||||
import { formatCurrency, formatDate } from '@/lib/helper';
|
||||
import { ReportExpense } from '@/types/api/report/report-expense';
|
||||
import { ReportExpenseApi } from '@/services/api/report';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import { ReportExpenseApi } from '@/services/api/report/expense-report';
|
||||
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
import Pagination from '@/components/Pagination';
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from '@/services/api/master-data';
|
||||
import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
@@ -73,6 +73,25 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// ===== SORTING STATE =====
|
||||
const [sortBy, setSortBy] = useState('');
|
||||
const [orderBy, setOrderBy] = useState('');
|
||||
|
||||
const sorting: SortingState = sortBy
|
||||
? [{ id: sortBy, desc: orderBy === 'desc' }]
|
||||
: [];
|
||||
|
||||
const handleSortingChange = (updater: Updater<SortingState>) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
if (next.length > 0) {
|
||||
setSortBy(next[0].id);
|
||||
setOrderBy(next[0].desc ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy('');
|
||||
setOrderBy('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterModalOpenRef = useRef(() => {});
|
||||
|
||||
const filterModal = useModal();
|
||||
@@ -126,8 +145,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
const restoredLocation = filterParams.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.location_id
|
||||
) || {
|
||||
value: filterParams.location_id,
|
||||
label: filterParams.location_id,
|
||||
}
|
||||
: null;
|
||||
const restoredSupplier = filterParams.supplier_id
|
||||
? supplierOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.supplier_id
|
||||
) || {
|
||||
value: filterParams.supplier_id,
|
||||
label: filterParams.supplier_id,
|
||||
}
|
||||
: null;
|
||||
const restoredKandang = filterParams.kandang_id
|
||||
? projectFlockKandangOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.kandang_id
|
||||
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
|
||||
: null;
|
||||
const restoredNonstock = filterParams.nonstock_id
|
||||
? nonstockOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.nonstock_id
|
||||
) || {
|
||||
value: filterParams.nonstock_id,
|
||||
label: filterParams.nonstock_id,
|
||||
}
|
||||
: null;
|
||||
const restoredCategory = filterParams.category
|
||||
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
|
||||
null
|
||||
: null;
|
||||
|
||||
formik.setValues({
|
||||
location_id: restoredLocation,
|
||||
supplier_id: restoredSupplier,
|
||||
kandang_id: restoredKandang,
|
||||
nonstock_id: restoredNonstock,
|
||||
realization_date: filterParams.realization_date || null,
|
||||
category: restoredCategory,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== OPTIONS =====
|
||||
@@ -189,26 +249,49 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
[formik.values.category]
|
||||
);
|
||||
|
||||
const buildReportExpenseQueryString = useCallback(
|
||||
(extraParams?: Record<string, string>) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filterParams.location_id) {
|
||||
params.append('location_id', filterParams.location_id);
|
||||
}
|
||||
if (filterParams.supplier_id) {
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
}
|
||||
if (filterParams.kandang_id) {
|
||||
params.append('project_flock_kandang_id', filterParams.kandang_id);
|
||||
}
|
||||
if (filterParams.nonstock_id) {
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
}
|
||||
if (filterParams.realization_date) {
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
}
|
||||
if (filterParams.category) {
|
||||
params.append('category', filterParams.category);
|
||||
}
|
||||
if (sortBy) params.append('sort_by', sortBy);
|
||||
if (orderBy) params.append('sort_order', orderBy);
|
||||
|
||||
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
|
||||
return params.toString();
|
||||
},
|
||||
[filterParams, sortBy, orderBy]
|
||||
);
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
const { data: reportExpenseResponse, isLoading } = useSWR(
|
||||
() => {
|
||||
const params = new URLSearchParams();
|
||||
if (filterParams.location_id)
|
||||
params.append('location_id', filterParams.location_id);
|
||||
if (filterParams.supplier_id)
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
if (filterParams.kandang_id)
|
||||
params.append('project_flock_kandang_id', filterParams.kandang_id);
|
||||
if (filterParams.nonstock_id)
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
if (filterParams.realization_date)
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
if (filterParams.category)
|
||||
params.append('category', filterParams.category);
|
||||
params.append('page', String(page));
|
||||
params.append('limit', String(pageSize));
|
||||
const queryString = buildReportExpenseQueryString({
|
||||
page: String(page),
|
||||
limit: String(pageSize),
|
||||
});
|
||||
|
||||
return [`${ReportExpenseApi.basePath}?${params.toString()}`];
|
||||
return [`${ReportExpenseApi.basePath}?${queryString}`];
|
||||
},
|
||||
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
|
||||
);
|
||||
@@ -233,47 +316,31 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
const reportExpenseExport = useCallback(async (): Promise<
|
||||
ReportExpense[] | null
|
||||
> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filterParams.location_id)
|
||||
params.append('location_id', filterParams.location_id);
|
||||
if (filterParams.supplier_id)
|
||||
params.append('supplier_id', filterParams.supplier_id);
|
||||
if (filterParams.kandang_id)
|
||||
params.append('kandang_id', filterParams.kandang_id);
|
||||
if (filterParams.nonstock_id)
|
||||
params.append('nonstock_id', filterParams.nonstock_id);
|
||||
if (filterParams.realization_date)
|
||||
params.append('realization_date', filterParams.realization_date);
|
||||
if (filterParams.category) params.append('category', filterParams.category);
|
||||
params.append('limit', '100');
|
||||
params.append('page', '1');
|
||||
const queryString = buildReportExpenseQueryString({
|
||||
page: '1',
|
||||
limit: '100',
|
||||
});
|
||||
|
||||
const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
|
||||
`${ReportExpenseApi.basePath}?${params.toString()}`
|
||||
`${ReportExpenseApi.basePath}?${queryString}`
|
||||
);
|
||||
|
||||
return isResponseSuccess(response) ? response.data : null;
|
||||
}, [filterParams]);
|
||||
}, [buildReportExpenseQueryString]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await reportExpenseExport();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
await generateReportExpenseExcel(allDataForExport);
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
||||
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
|
||||
);
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [reportExpenseExport]);
|
||||
}, [buildReportExpenseQueryString]);
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
@@ -397,19 +464,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
return [
|
||||
{
|
||||
header: 'No',
|
||||
enableSorting: false,
|
||||
cell: (props) => (page - 1) * pageSize + props.row.index + 1,
|
||||
},
|
||||
{
|
||||
header: 'No. PO',
|
||||
accessorKey: 'po_number',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'No. Referensi',
|
||||
accessorKey: 'reference_number',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'Tanggal Realisasi',
|
||||
accessorKey: 'realization_date',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
|
||||
},
|
||||
@@ -417,6 +488,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
{
|
||||
header: 'Tanggal Transaksi',
|
||||
accessorKey: 'transaction_date',
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
|
||||
},
|
||||
@@ -424,21 +496,30 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
{
|
||||
header: 'Kategori',
|
||||
accessorKey: 'category',
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
header: 'Produk',
|
||||
accessorKey: 'product',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.pengajuan?.nonstock?.name,
|
||||
},
|
||||
{
|
||||
header: 'Supplier',
|
||||
accessorKey: 'supplier',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.supplier?.name,
|
||||
},
|
||||
{
|
||||
header: 'Lokasi',
|
||||
accessorKey: 'location',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.kandang?.location?.name,
|
||||
},
|
||||
{
|
||||
header: 'Kandang',
|
||||
accessorKey: 'kandang',
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => row.kandang?.name,
|
||||
},
|
||||
{
|
||||
@@ -446,23 +527,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
columns: [
|
||||
{
|
||||
header: 'Qty',
|
||||
id: 'qty_pengajuan',
|
||||
accessorFn: (row) => row.pengajuan?.qty,
|
||||
accessorKey: 'qty_pengajuan',
|
||||
cell: ({ row }) =>
|
||||
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
|
||||
},
|
||||
{
|
||||
header: 'Harga',
|
||||
id: 'harga_pengajuan',
|
||||
accessorFn: (row) => row.pengajuan?.price,
|
||||
accessorKey: 'price_pengajuan',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(row.original.pengajuan?.price || 0),
|
||||
},
|
||||
{
|
||||
header: 'Total',
|
||||
id: 'total_pengajuan',
|
||||
accessorFn: (row) =>
|
||||
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
|
||||
accessorKey: 'total_pengajuan',
|
||||
cell: ({ row }) => {
|
||||
const total =
|
||||
(row.original.pengajuan?.qty || 0) *
|
||||
@@ -477,23 +554,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
columns: [
|
||||
{
|
||||
header: 'Qty',
|
||||
id: 'qty_realisasi',
|
||||
accessorFn: (row) => row.realisasi?.qty,
|
||||
accessorKey: 'qty_realisasi',
|
||||
cell: ({ row }) =>
|
||||
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
|
||||
},
|
||||
{
|
||||
header: 'Harga',
|
||||
id: 'harga_realisasi',
|
||||
accessorFn: (row) => row.realisasi?.price,
|
||||
accessorKey: 'price_realisasi',
|
||||
cell: ({ row }) =>
|
||||
formatCurrency(row.original.realisasi?.price || 0),
|
||||
},
|
||||
{
|
||||
header: 'Total',
|
||||
id: 'total_realisasi',
|
||||
accessorFn: (row) =>
|
||||
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
|
||||
accessorKey: 'total_realisasi',
|
||||
cell: ({ row }) => {
|
||||
const total =
|
||||
(row.original.realisasi?.qty || 0) *
|
||||
@@ -504,6 +577,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'realization_status',
|
||||
header: 'Status Pencairan',
|
||||
cell: (props) => (
|
||||
<RealizationStatusBadge
|
||||
@@ -512,6 +586,7 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bop_status',
|
||||
header: 'Status BOP',
|
||||
cell: (props) => (
|
||||
<ExpenseStatusBadge approval={props.row.original?.latest_approval} />
|
||||
@@ -556,6 +631,9 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
|
||||
totalItems={meta?.total_results || 0}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
sorting={sorting}
|
||||
setSorting={handleSortingChange}
|
||||
manualSorting
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
tableWrapperClassName: 'overflow-x-auto',
|
||||
|
||||
@@ -38,6 +38,7 @@ import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton
|
||||
import { OptionType } from '@/components/table/TableRowSizeSelector';
|
||||
import { Color } from '@/types/theme';
|
||||
import ButtonFilter from '@/components/helper/ButtonFilter';
|
||||
import Pagination from '@/components/Pagination';
|
||||
|
||||
interface CustomerPaymentTabProps {
|
||||
tabId: string;
|
||||
@@ -58,7 +59,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
|
||||
// ===== PAGINATION STATE =====
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// ===== SUBMISSION STATE =====
|
||||
const [filterParams, setFilterParams] = useState<FilterParams>({});
|
||||
@@ -117,8 +118,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
start_date: filterParams.start_date || null,
|
||||
end_date: filterParams.end_date || null,
|
||||
customer_ids: filterParams.customer_ids || null,
|
||||
filter_by: filterParams.filter_by || null,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const getPaymentStatusBadgeColor = (notes: string): Color => {
|
||||
@@ -249,6 +255,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
[customerPayment]
|
||||
);
|
||||
|
||||
const meta = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(customerPayment) && customerPayment.meta
|
||||
? customerPayment.meta
|
||||
: null,
|
||||
[customerPayment]
|
||||
);
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const customerPaymentExport = useCallback(async (): Promise<
|
||||
CustomerPaymentReport[] | null
|
||||
@@ -717,6 +731,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='w-full ml-auto'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={meta.page || 0}
|
||||
onPrevPage={() =>
|
||||
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
|
||||
}
|
||||
onNextPage={() =>
|
||||
setCurrentPage((curr) =>
|
||||
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
|
||||
)
|
||||
}
|
||||
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
data.length > 0 &&
|
||||
data.map((customerReport) => {
|
||||
@@ -811,6 +846,27 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='mt-5 px-3'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={meta.page || 0}
|
||||
onPrevPage={() =>
|
||||
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
|
||||
}
|
||||
onNextPage={() =>
|
||||
setCurrentPage((curr) =>
|
||||
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
|
||||
)
|
||||
}
|
||||
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
import { OptionType, useSelect } from '@/components/input/SelectInput';
|
||||
import Modal, { useModal } from '@/components/Modal';
|
||||
@@ -78,6 +79,10 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
|
||||
const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
|
||||
|
||||
// ===== PAGINATION STATE =====
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// ===== SUBMISSION STATE =====
|
||||
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
|
||||
start_date: undefined,
|
||||
@@ -128,7 +133,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
filter_by: values.filterBy?.value?.toString() || undefined,
|
||||
});
|
||||
filterModal.closeModal();
|
||||
// setIsSubmitted(true);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
onReset: () => {
|
||||
setFilterParams({
|
||||
@@ -137,14 +142,30 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
supplier_ids: undefined,
|
||||
filter_by: undefined,
|
||||
});
|
||||
// setIsSubmitted(false);
|
||||
setCurrentPage(1);
|
||||
filterModal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
const restoredFilterBy =
|
||||
dataTypeOptions.find((opt) => opt.value === filterParams.filter_by) ||
|
||||
null;
|
||||
|
||||
const supplierIdList = filterParams.supplier_ids
|
||||
? filterParams.supplier_ids.split(',')
|
||||
: [];
|
||||
const restoredSupplierIds = supplierOptions.filter((opt) =>
|
||||
supplierIdList.includes(String(opt.value))
|
||||
);
|
||||
|
||||
formik.setValues({
|
||||
startDate: filterParams.start_date || null,
|
||||
endDate: filterParams.end_date || null,
|
||||
supplierIds: restoredSupplierIds.length > 0 ? restoredSupplierIds : null,
|
||||
filterBy: restoredFilterBy,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== DATA FETCHING =====
|
||||
@@ -155,6 +176,8 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
filter_by: filterParams.filter_by,
|
||||
start_date: filterParams.start_date,
|
||||
end_date: filterParams.end_date,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
return ['debt-supplier-report', params];
|
||||
@@ -164,7 +187,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
params.supplier_ids,
|
||||
params.filter_by,
|
||||
params.start_date,
|
||||
params.end_date
|
||||
params.end_date,
|
||||
params.page,
|
||||
params.limit
|
||||
)
|
||||
);
|
||||
|
||||
@@ -176,6 +201,14 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
[debtSupplier]
|
||||
);
|
||||
|
||||
const meta = useMemo(
|
||||
() =>
|
||||
isResponseSuccess(debtSupplier) && debtSupplier.meta
|
||||
? debtSupplier.meta
|
||||
: null,
|
||||
[debtSupplier]
|
||||
);
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const debtSupplierExport = useCallback(async (): Promise<
|
||||
DebtSupplier[] | null
|
||||
@@ -630,6 +663,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='w-full ml-auto'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={meta.page || 0}
|
||||
onPrevPage={() =>
|
||||
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
|
||||
}
|
||||
onNextPage={() =>
|
||||
setCurrentPage((curr) =>
|
||||
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
|
||||
)
|
||||
}
|
||||
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
data.length > 0 &&
|
||||
data.map((supplierReport) => {
|
||||
@@ -717,6 +771,27 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isLoading && data.length > 0 && meta && (
|
||||
<div className='mt-5 px-3'>
|
||||
<Pagination
|
||||
totalItems={meta.total_results || 0}
|
||||
itemsPerPage={meta.limit || 0}
|
||||
currentPage={meta.page || 0}
|
||||
onPrevPage={() =>
|
||||
setCurrentPage((curr) => (curr > 1 ? curr - 1 : curr))
|
||||
}
|
||||
onNextPage={() =>
|
||||
setCurrentPage((curr) =>
|
||||
meta.total_pages && curr < meta.total_pages ? curr + 1 : curr
|
||||
)
|
||||
}
|
||||
onPageChange={(pageNumber) => setCurrentPage(pageNumber)}
|
||||
rowOptions={[10, 20, 50, 100]}
|
||||
onRowChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Modal */}
|
||||
|
||||
@@ -156,8 +156,17 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
start_date: filterParams.start_date || null,
|
||||
end_date: filterParams.end_date || null,
|
||||
area_ids: filterParams.area_id || null,
|
||||
supplier_ids: filterParams.supplier_id || null,
|
||||
product_ids: filterParams.product_id || null,
|
||||
product_category_ids: filterParams.product_category_id || null,
|
||||
filter_by: filterParams.filter_by || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export type DailyMarketingReportFilterType = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search: string | null;
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
@@ -14,6 +16,8 @@ export type DailyMarketingReportFilterType = {
|
||||
};
|
||||
|
||||
export const DailyMarketingReportFilterSchema = yup.object({
|
||||
page: yup.number().nullable(),
|
||||
pageSize: yup.number().nullable(),
|
||||
search: yup.string().nullable(),
|
||||
area_id: yup.string().nullable(),
|
||||
location_id: yup.string().nullable(),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export type HppPerKandangFilterType = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id: string | null;
|
||||
location_id: string | null;
|
||||
kandang_id: string | null;
|
||||
@@ -12,6 +14,8 @@ export type HppPerKandangFilterType = {
|
||||
};
|
||||
|
||||
export const HppPerKandangFilterSchema = yup.object({
|
||||
page: yup.number().nullable(),
|
||||
pageSize: yup.number().nullable(),
|
||||
area_id: yup.string().nullable(),
|
||||
location_id: yup.string().nullable(),
|
||||
kandang_id: yup.string().nullable(),
|
||||
|
||||
@@ -17,16 +17,10 @@ import {
|
||||
formatVechicleNumber,
|
||||
formatTitleCase,
|
||||
} from '@/lib/helper';
|
||||
import {
|
||||
DailyMarketingRow,
|
||||
DailyMarketingReportResponse,
|
||||
} from '@/types/api/report/marketing';
|
||||
import { DailyMarketingRow } from '@/types/api/report/marketing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
|
||||
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
|
||||
import { useEffect as useEffectHook } from 'react';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import {
|
||||
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
||||
MARKETING_TYPE_OPTIONS,
|
||||
@@ -53,6 +45,8 @@ interface DailyMarketingTabProps {
|
||||
}
|
||||
|
||||
interface FilterParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id?: string;
|
||||
location_id?: string;
|
||||
warehouse_id?: string;
|
||||
@@ -116,6 +110,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<DailyMarketingReportFilterType>({
|
||||
initialValues: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
search: null,
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
@@ -130,6 +126,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
validationSchema: DailyMarketingReportFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
setFilterParams({
|
||||
page: values.page || undefined,
|
||||
pageSize: values.pageSize || undefined,
|
||||
area_id: values.area_id || undefined,
|
||||
location_id: values.location_id || undefined,
|
||||
warehouse_id: values.warehouse_id || undefined,
|
||||
@@ -150,8 +148,21 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
page: formik.values.page,
|
||||
pageSize: formik.values.pageSize,
|
||||
search: formik.values.search,
|
||||
area_id: filterParams.area_id || null,
|
||||
location_id: filterParams.location_id || null,
|
||||
warehouse_id: filterParams.warehouse_id || null,
|
||||
customer_id: filterParams.customer_id || null,
|
||||
start_date: filterParams.start_date || null,
|
||||
end_date: filterParams.end_date || null,
|
||||
filter_by: filterParams.filter_by || null,
|
||||
marketing_type: filterParams.marketing_type || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== SEARCH CHANGE HANDLER =====
|
||||
@@ -222,6 +233,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.page) params.set('page', String(filterParams.page));
|
||||
if (filterParams.pageSize)
|
||||
params.set('limit', String(filterParams.pageSize));
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
@@ -262,67 +276,30 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
[dailyMarketings]
|
||||
);
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const dailyMarketingsExport = useCallback(async (): Promise<
|
||||
DailyMarketingRow[] | null
|
||||
> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
params.set('limit', '9999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await httpClient<DailyMarketingReportResponse>(
|
||||
`${MarketingReportApi.basePath}${queryString}`
|
||||
);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
|
||||
const period =
|
||||
filterParams.start_date && filterParams.end_date
|
||||
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
|
||||
: undefined;
|
||||
|
||||
await generateDailyMarketingExcel({
|
||||
data: allDataForExport,
|
||||
summaryTotal: summaryTotal,
|
||||
period: period,
|
||||
});
|
||||
await MarketingReportApi.exportDailyMarketingToExcel(params.toString());
|
||||
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
@@ -330,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [filterParams, dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
|
||||
const dailyMarketingReportPdfBlob = await pdf(
|
||||
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
|
||||
).toBlob();
|
||||
await MarketingReportApi.exportDailyMarketingToPDF(params.toString());
|
||||
|
||||
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
||||
dailyMarketingReportPdfBlob
|
||||
);
|
||||
window.open(dailyMarketingReportPdfUrl, '_blank');
|
||||
|
||||
toast.success('PDF berhasil dibuat.');
|
||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== TAB ACTIONS COMPONENT =====
|
||||
const TabActions = useMemo(() => {
|
||||
@@ -572,7 +554,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'qty',
|
||||
cell: (props) => formatNumber(props.row.original.qty),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_qty
|
||||
? formatNumber(summaryTotal.total_qty)
|
||||
: '-'}
|
||||
@@ -585,7 +567,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'average_weight_kg',
|
||||
cell: (props) => formatNumber(props.row.original.average_weight_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.average_weight_kg
|
||||
? formatNumber(summaryTotal.average_weight_kg)
|
||||
: '-'}
|
||||
@@ -598,7 +580,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'total_weight_kg',
|
||||
cell: (props) => formatNumber(props.row.original.total_weight_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_weight_kg
|
||||
? formatNumber(summaryTotal.total_weight_kg)
|
||||
: '-'}
|
||||
@@ -611,9 +593,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'sales_price_per_kg',
|
||||
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.average_sales_price
|
||||
? formatNumber(summaryTotal.average_sales_price)
|
||||
? formatCurrency(summaryTotal.average_sales_price)
|
||||
: '-'}
|
||||
</div>
|
||||
),
|
||||
@@ -624,7 +606,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'hpp_price_per_kg',
|
||||
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_hpp_price_per_kg
|
||||
? formatCurrency(summaryTotal.total_hpp_price_per_kg)
|
||||
: '-'}
|
||||
@@ -637,7 +619,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
accessorKey: 'sales_amount',
|
||||
cell: (props) => formatCurrency(props.row.original.sales_amount),
|
||||
footer: () => (
|
||||
<div className='text-right font-semibold text-gray-900'>
|
||||
<div className='font-semibold text-gray-900'>
|
||||
{summaryTotal?.total_sales_amount
|
||||
? formatCurrency(summaryTotal.total_sales_amount)
|
||||
: '-'}
|
||||
@@ -688,6 +670,27 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
<Table
|
||||
data={data}
|
||||
columns={getTableColumns()}
|
||||
pageSize={filterParams.pageSize}
|
||||
page={
|
||||
isResponseSuccess(dailyMarketings)
|
||||
? dailyMarketings?.meta?.page
|
||||
: 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(dailyMarketings)
|
||||
? dailyMarketings?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={(newPage) =>
|
||||
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setFilterParams((prevVal) => ({
|
||||
...prevVal,
|
||||
pageSize: newPageSize,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading}
|
||||
renderFooter={data.length > 0}
|
||||
className={{
|
||||
containerClassName: 'w-full mb-0!',
|
||||
|
||||
@@ -40,6 +40,8 @@ interface HppPerKandangTabProps {
|
||||
}
|
||||
|
||||
interface FilterParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
area_id?: string;
|
||||
location_id?: string;
|
||||
kandang_id?: string;
|
||||
@@ -108,6 +110,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<HppPerKandangFilterType>({
|
||||
initialValues: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
area_id: null,
|
||||
location_id: null,
|
||||
kandang_id: null,
|
||||
@@ -120,6 +124,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
validationSchema: HppPerKandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
setFilterParams({
|
||||
page: values.page || undefined,
|
||||
pageSize: values.pageSize || undefined,
|
||||
area_id: values.area_id || undefined,
|
||||
location_id: values.location_id || undefined,
|
||||
kandang_id: values.kandang_id || undefined,
|
||||
@@ -146,8 +152,19 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
formik.setValues({
|
||||
page: formik.values.page,
|
||||
pageSize: formik.values.pageSize,
|
||||
area_id: filterParams.area_id || null,
|
||||
location_id: filterParams.location_id || null,
|
||||
kandang_id: filterParams.kandang_id || null,
|
||||
weight_min: filterParams.weight_min || null,
|
||||
weight_max: filterParams.weight_max || null,
|
||||
period: filterParams.period || null,
|
||||
sort_by: filterParams.sort_by || null,
|
||||
show_unrecorded: filterParams.show_unrecorded ?? false,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
// ===== WEIGHT CHANGE HANDLERS =====
|
||||
@@ -257,6 +274,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
period: filterParams.period,
|
||||
sort_by: filterParams.sort_by,
|
||||
show_unrecorded: filterParams.show_unrecorded,
|
||||
page: filterParams.page,
|
||||
pageSize: filterParams.pageSize,
|
||||
};
|
||||
|
||||
return ['hpp-per-kandang-report', params];
|
||||
@@ -271,7 +290,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
params.weight_max,
|
||||
params.period,
|
||||
params.sort_by,
|
||||
params.show_unrecorded
|
||||
params.show_unrecorded,
|
||||
params.page,
|
||||
params.pageSize
|
||||
)
|
||||
);
|
||||
|
||||
@@ -321,7 +342,9 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
params.weight_max,
|
||||
params.period,
|
||||
params.sort_by,
|
||||
params.show_unrecorded
|
||||
params.show_unrecorded,
|
||||
params.page,
|
||||
params.limit
|
||||
);
|
||||
|
||||
return isResponseSuccess(response) ? response.data : null;
|
||||
@@ -466,6 +489,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
<div className='flex flex-row gap-3'>
|
||||
<ButtonFilter
|
||||
values={filterParams}
|
||||
excludeFields={['page', 'pageSize']}
|
||||
onClick={() => handleFilterModalOpenRef.current()}
|
||||
variant='outline'
|
||||
className='px-3 py-2.5'
|
||||
@@ -845,6 +869,25 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
|
||||
<Table
|
||||
data={data}
|
||||
columns={getTableColumns()}
|
||||
pageSize={filterParams.pageSize}
|
||||
page={
|
||||
isResponseSuccess(hppPerKandang) ? hppPerKandang?.meta?.page : 0
|
||||
}
|
||||
totalItems={
|
||||
isResponseSuccess(hppPerKandang)
|
||||
? hppPerKandang?.meta?.total_results
|
||||
: 0
|
||||
}
|
||||
onPageChange={(newPage) =>
|
||||
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
|
||||
}
|
||||
onPageSizeChange={(newPageSize) =>
|
||||
setFilterParams((prevVal) => ({
|
||||
...prevVal,
|
||||
pageSize: newPageSize,
|
||||
}))
|
||||
}
|
||||
isLoading={isLoading}
|
||||
renderFooter={data.length > 0}
|
||||
renderCustomRow={renderCustomRow}
|
||||
className={{
|
||||
|
||||
+36
-1
@@ -263,8 +263,43 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
|
||||
});
|
||||
|
||||
handleFilterModalOpenRef.current = () => {
|
||||
const restoredAreaId = filterParams.area_id
|
||||
? areaOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.area_id
|
||||
) || { value: filterParams.area_id, label: filterParams.area_id }
|
||||
: null;
|
||||
const restoredLocationId = filterParams.location_id
|
||||
? locationOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.location_id
|
||||
) || {
|
||||
value: filterParams.location_id,
|
||||
label: filterParams.location_id,
|
||||
}
|
||||
: null;
|
||||
const restoredProjectFlockId = filterParams.project_flock_id
|
||||
? projectFlockOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.project_flock_id
|
||||
) || {
|
||||
value: filterParams.project_flock_id,
|
||||
label: filterParams.project_flock_id,
|
||||
}
|
||||
: null;
|
||||
const restoredKandangId = filterParams.project_flock_kandang_id
|
||||
? projectFlockKandangOptions.find(
|
||||
(opt) => String(opt.value) === filterParams.project_flock_kandang_id
|
||||
) || {
|
||||
value: filterParams.project_flock_kandang_id,
|
||||
label: filterParams.project_flock_kandang_id,
|
||||
}
|
||||
: null;
|
||||
|
||||
formik.setValues({
|
||||
area_id: restoredAreaId,
|
||||
location_id: restoredLocationId,
|
||||
project_flock_id: restoredProjectFlockId,
|
||||
kandang_id: restoredKandangId,
|
||||
});
|
||||
filterModal.openModal();
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
|
||||
|
||||
+11
-1
@@ -197,6 +197,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
icon: 'heroicons-outline:folder',
|
||||
permission: [
|
||||
'lti.inventory.product_stock.list',
|
||||
'lti.inventory.stock_log.list',
|
||||
'lti.inventory.product_warehouses.list',
|
||||
'lti.inventory.transfer.list',
|
||||
],
|
||||
@@ -204,7 +205,10 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
{
|
||||
text: 'Stok Produk',
|
||||
link: '/inventory/product',
|
||||
permission: ['lti.inventory.product_stock.list'],
|
||||
permission: [
|
||||
'lti.inventory.product_stock.list',
|
||||
'lti.inventory.stock_log.list',
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Penyesuaian Stok',
|
||||
@@ -236,6 +240,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
'lti.master.uoms.list',
|
||||
'lti.master.warehouses.list',
|
||||
'lti.master.production_standards.list',
|
||||
'lti.system_settings.update',
|
||||
],
|
||||
submenu: [
|
||||
{
|
||||
@@ -303,6 +308,11 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
|
||||
link: '/master-data/production-standard',
|
||||
permission: ['lti.master.production_standards.list'],
|
||||
},
|
||||
{
|
||||
text: 'Konfigurasi Sistem',
|
||||
link: '/master-data/system-config',
|
||||
permission: ['lti.system_settings.update'],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -218,4 +218,6 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
|
||||
'/master-data/production-standard/detail/edit/': [
|
||||
'lti.master.production_standards.update',
|
||||
],
|
||||
|
||||
'/master-data/system-config/': ['lti.system_settings.update'],
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ interface DatePickerProps {
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
formatDisplay?: (date: string) => string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
@@ -28,6 +29,7 @@ export function DatePicker({
|
||||
disabled = false,
|
||||
placeholder = 'Select date',
|
||||
formatDisplay,
|
||||
hasError = false,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
@@ -154,7 +156,7 @@ export function DatePicker({
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={disabled}
|
||||
className='w-full justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
|
||||
className={`w-full justify-start text-left font-normal hover:bg-gray-50 ${hasError ? 'border-red-500 focus:ring-red-500' : 'border-gray-200'}`}
|
||||
>
|
||||
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
|
||||
{date ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,10 @@ export function Dashboard() {
|
||||
options: kandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
|
||||
order_by: 'asc',
|
||||
sort_by: 'name',
|
||||
});
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
|
||||
+320
-37
@@ -40,11 +40,12 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import Table from '@/components/Table';
|
||||
import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
|
||||
import { cn } from '@/lib/helper';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import RequirePermission from '@/components/helper/RequirePermission';
|
||||
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
|
||||
import CheckboxInput from '@/components/input/CheckboxInput';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'ALL', label: 'Semua Status' },
|
||||
@@ -59,6 +60,7 @@ const CATEGORY_LABELS: { [key: string]: string } = {
|
||||
pullet_close: 'Pullet Close',
|
||||
produksi_open: 'Produksi Open',
|
||||
produksi_close: 'Produksi Close',
|
||||
empty_kandang: 'Kandang Kosong',
|
||||
};
|
||||
|
||||
export function ListDailyChecklistContent() {
|
||||
@@ -87,6 +89,9 @@ export function ListDailyChecklistContent() {
|
||||
date_from: 'date_from',
|
||||
date_to: 'date_to',
|
||||
},
|
||||
|
||||
persist: true,
|
||||
storeName: 'list-daily-checklist-content-table',
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -105,7 +110,10 @@ export function ListDailyChecklistContent() {
|
||||
options: kandangOptions,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
|
||||
order_by: 'asc',
|
||||
sort_by: 'name',
|
||||
});
|
||||
|
||||
const checklistList = isResponseSuccess(checklistListRes)
|
||||
? checklistListRes.data || []
|
||||
@@ -122,12 +130,29 @@ export function ListDailyChecklistContent() {
|
||||
|
||||
// Modals
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showBulkApproveModal, setShowBulkApproveModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [showBulkRejectModal, setShowBulkRejectModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const selectedRowIds = Object.keys(rowSelection);
|
||||
|
||||
const selectedRowItems = selectedRowIds.map((itemId) =>
|
||||
checklistList.find((item) => item.id === parseInt(itemId))
|
||||
);
|
||||
|
||||
const tableEnableRowSelectionHandler: (
|
||||
row: Row<DailyChecklist>
|
||||
) => boolean = (row) => {
|
||||
return (
|
||||
row.original.status !== 'APPROVED' && row.original.status !== 'REJECTED'
|
||||
);
|
||||
};
|
||||
|
||||
const handleDetail = (item: DailyChecklist) => {
|
||||
router.push(
|
||||
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}`
|
||||
@@ -135,13 +160,7 @@ export function ListDailyChecklistContent() {
|
||||
};
|
||||
|
||||
const handleEdit = (item: DailyChecklist) => {
|
||||
const formattedDate = new Date(item.date).toISOString().split('T')[0];
|
||||
const kandangId = item.kandang?.id ?? '';
|
||||
const category = item.category;
|
||||
|
||||
router.push(
|
||||
`/daily-checklist/daily-checklist?date=${formattedDate}&kandang_id=${kandangId}&category=${category}`
|
||||
);
|
||||
router.push(`/daily-checklist/daily-checklist?checklistId=${item.id}`);
|
||||
};
|
||||
|
||||
const handleApprove = (item: DailyChecklist) => {
|
||||
@@ -149,21 +168,22 @@ export function ListDailyChecklistContent() {
|
||||
setShowApproveModal(true);
|
||||
};
|
||||
|
||||
const handleBulkApprove = () => {
|
||||
setShowBulkApproveModal(true);
|
||||
};
|
||||
|
||||
const handleReject = (item: DailyChecklist) => {
|
||||
setSelectedItem(item);
|
||||
setRejectReason('');
|
||||
setShowRejectModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (item: DailyChecklist) => {
|
||||
// ✅ VALIDATION: Only DRAFT can be deleted
|
||||
if (item.status !== 'DRAFT') {
|
||||
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
|
||||
description: `Status saat ini: ${item.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleBulkReject = () => {
|
||||
setRejectReason('');
|
||||
setShowBulkRejectModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (item: DailyChecklist) => {
|
||||
setSelectedItem(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
@@ -195,6 +215,31 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkApprove = async () => {
|
||||
if (!selectedRowIds.length) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const approveRes = await DailyChecklistApi.bulkApprove(selectedRowIds);
|
||||
|
||||
if (isResponseError(approveRes)) {
|
||||
toast.error('Gagal approve checklist: ' + approveRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshChecklistList();
|
||||
toast.success('Checklist berhasil di-approve');
|
||||
setShowBulkApproveModal(false);
|
||||
setRowSelection({});
|
||||
} catch (error) {
|
||||
console.error('Error approving checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
@@ -229,6 +274,40 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkReject = async () => {
|
||||
if (!selectedRowIds.length) return;
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error('Alasan reject harus diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
|
||||
const rejectRes = await DailyChecklistApi.bulkReject(
|
||||
selectedRowIds,
|
||||
rejectReason
|
||||
);
|
||||
|
||||
if (isResponseError(rejectRes)) {
|
||||
toast.error('Gagal reject checklist: ' + rejectRes.message);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshChecklistList();
|
||||
toast.success('Checklist berhasil di-reject');
|
||||
setShowBulkRejectModal(false);
|
||||
setRowSelection({});
|
||||
setRejectReason('');
|
||||
} catch (error) {
|
||||
console.error('Error rejecting checklist:', error);
|
||||
toast.error('Terjadi kesalahan');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
@@ -325,6 +404,37 @@ export function ListDailyChecklistContent() {
|
||||
};
|
||||
|
||||
const checklistListColumns: ColumnDef<DailyChecklist>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<div className='w-full flex flex-row justify-center'>
|
||||
<CheckboxInput
|
||||
name='allRow'
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isCheckboxDisabled =
|
||||
!row.getCanSelect() ||
|
||||
row.original.status === 'APPROVED' ||
|
||||
row.original.status === 'REJECTED';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CheckboxInput
|
||||
name='row'
|
||||
checked={row.getIsSelected()}
|
||||
disabled={isCheckboxDisabled}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'date',
|
||||
header: 'Tanggal',
|
||||
@@ -437,19 +547,17 @@ export function ListDailyChecklistContent() {
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
{row.original.status === 'DRAFT' && (
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(row.original)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<Trash2 className='w-4 h-4 mr-1' />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={() => handleDelete(row.original)}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<Trash2 className='w-4 h-4 mr-1' />
|
||||
Hapus
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -459,13 +567,39 @@ export function ListDailyChecklistContent() {
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
List Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Daftar semua checklist harian
|
||||
</p>
|
||||
<div className='mb-6 flex flex-row justify-between items-center gap-3'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
List Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Daftar semua checklist harian
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
{selectedRowIds.length > 0 && (
|
||||
<div className='flex flex-row items-center gap-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleBulkApprove}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
<CheckCircle className='w-4 h-4 mr-1' />
|
||||
Bulk Approve {`(${selectedRowIds.length}) item`}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={handleBulkReject}
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<XCircle className='w-4 h-4 mr-1' />
|
||||
Bulk Reject {`(${selectedRowIds.length}) item`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
@@ -588,6 +722,10 @@ export function ListDailyChecklistContent() {
|
||||
}
|
||||
onPageChange={setPage}
|
||||
isLoading={isLoadingChecklistList}
|
||||
rowSelection={rowSelection}
|
||||
setRowSelection={setRowSelection}
|
||||
enableRowSelection={tableEnableRowSelectionHandler}
|
||||
withCheckbox
|
||||
className={{
|
||||
containerClassName: cn({
|
||||
'w-full mb-20':
|
||||
@@ -666,6 +804,76 @@ export function ListDailyChecklistContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Approve Modal */}
|
||||
<Dialog
|
||||
open={showBulkApproveModal}
|
||||
onOpenChange={setShowBulkApproveModal}
|
||||
>
|
||||
<DialogContent className='sm:max-w-md max-h-[80vh] overflow-y-auto bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Approve Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Apakah Anda yakin ingin approve {selectedRowIds.length} checklist
|
||||
ini?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
|
||||
{selectedRowItems.map((item) => (
|
||||
<div
|
||||
key={item?.id ?? 0}
|
||||
className='bg-gray-50 rounded-lg p-4 space-y-2'
|
||||
>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(item?.date ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.kandang?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.category
|
||||
? (CATEGORY_LABELS[item.category] ?? item?.category)
|
||||
: item?.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Progress:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowBulkApproveModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmBulkApprove}
|
||||
disabled={actionLoading}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reject Modal */}
|
||||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
@@ -735,6 +943,81 @@ export function ListDailyChecklistContent() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Reject Modal */}
|
||||
<Dialog open={showBulkRejectModal} onOpenChange={setShowBulkRejectModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject Checklist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Berikan alasan reject untuk checklist ini
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
|
||||
{selectedRowItems.map((item) => (
|
||||
<div
|
||||
key={item?.id ?? 0}
|
||||
className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'
|
||||
>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Tanggal:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{formatDate(item?.date ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kandang:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.kandang?.name ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<span className='text-gray-600'>Kategori:</span>
|
||||
<span className='font-medium text-gray-900'>
|
||||
{item?.category
|
||||
? CATEGORY_LABELS[item.category] || item?.category
|
||||
: item?.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='reject-reason'>
|
||||
Alasan Reject <span className='text-red-500'>*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id='reject-reason'
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder='Tuliskan alasan reject...'
|
||||
className='mt-1.5 border-gray-200 min-h-[100px]'
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowBulkRejectModal(false)}
|
||||
disabled={actionLoading}
|
||||
className='border-gray-200'
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmBulkReject}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
|
||||
|
||||
+190
-33
@@ -2,7 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import * as htmlToImage from 'html-to-image';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { Button } from '@/figma-make/components/base/button';
|
||||
import { Badge } from '@/figma-make/components/base/badge';
|
||||
@@ -53,6 +60,7 @@ interface ChecklistHeader {
|
||||
progress_percent: number;
|
||||
total_phases: number;
|
||||
total_activities: number;
|
||||
empty_kandang_end_date?: string | null;
|
||||
}
|
||||
|
||||
interface PhaseGroup {
|
||||
@@ -106,6 +114,7 @@ const CATEGORY_LABELS: { [key: string]: string } = {
|
||||
pullet_close: 'Pullet Close',
|
||||
produksi_open: 'Produksi Open',
|
||||
produksi_close: 'Produksi Close',
|
||||
empty_kandang: 'Kandang Kosong',
|
||||
};
|
||||
|
||||
const TIME_TYPE_ORDER = ['Umum', 'Pagi', 'Siang', 'Sore', 'Malam'];
|
||||
@@ -137,6 +146,8 @@ export function DetailDailyChecklistContent() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (checklistId) {
|
||||
fetchChecklistDetail();
|
||||
@@ -169,6 +180,9 @@ export function DetailDailyChecklistContent() {
|
||||
|
||||
setDocuments(rawDetailChecklist?.document_urls || []);
|
||||
|
||||
const emptyKandangEndDate =
|
||||
rawDetailChecklist?.empty_kandang?.end_date ?? null;
|
||||
|
||||
const checklistData = {
|
||||
id: rawDetailChecklist?.id,
|
||||
date: rawDetailChecklist?.date,
|
||||
@@ -195,6 +209,7 @@ export function DetailDailyChecklistContent() {
|
||||
progress_percent: 0,
|
||||
total_phases: 0,
|
||||
total_activities: 0,
|
||||
empty_kandang_end_date: emptyKandangEndDate,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -262,6 +277,7 @@ export function DetailDailyChecklistContent() {
|
||||
progress_percent: 0,
|
||||
total_phases: new Set(tasks.map((t) => t.phase_id)).size,
|
||||
total_activities: tasks.length,
|
||||
empty_kandang_end_date: emptyKandangEndDate,
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -312,6 +328,7 @@ export function DetailDailyChecklistContent() {
|
||||
progress_percent: progressPercent,
|
||||
total_phases: uniquePhases.size,
|
||||
total_activities: uniqueActivities.size,
|
||||
empty_kandang_end_date: emptyKandangEndDate,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching checklist detail:', error);
|
||||
@@ -547,6 +564,103 @@ export function DetailDailyChecklistContent() {
|
||||
});
|
||||
};
|
||||
|
||||
const isMobileDevice = () => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (header?.status) {
|
||||
case 'DRAFT':
|
||||
return 'Checklist harian perlu disubmit';
|
||||
case 'SUBMITTED':
|
||||
return 'Checklist harian menunggu persetujuan';
|
||||
case 'APPROVED':
|
||||
return 'Checklist harian telah disetujui';
|
||||
case 'REJECTED':
|
||||
return 'Checklist harian telah ditolak';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const shareHandler = async () => {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (isMobile) {
|
||||
setIsGeneratingImage(true);
|
||||
}
|
||||
|
||||
const baseTitle = `Daily Checklist - ${formatDate(header?.date || '')} - ${header?.kandang_name} - ${header?.category}`;
|
||||
const statusMsg = getStatusMessage();
|
||||
const statusInfo = `\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}`;
|
||||
const urlMessage = `\n\nView full checklist: ${window.location.href}`;
|
||||
const fullMessage = baseTitle + statusInfo + urlMessage;
|
||||
|
||||
let shareData: ShareData;
|
||||
|
||||
if (isMobile) {
|
||||
const htmlBlob = await htmlToImage.toBlob(document.body, {
|
||||
backgroundColor: '#ffffff',
|
||||
});
|
||||
const imgFile = new File(
|
||||
[htmlBlob!],
|
||||
`daily-checklist-${header?.date}-${header?.kandang_name}-${header?.category}.png`,
|
||||
{
|
||||
type: 'image/png',
|
||||
}
|
||||
);
|
||||
|
||||
shareData = {
|
||||
files: [imgFile],
|
||||
title: baseTitle,
|
||||
text: fullMessage,
|
||||
};
|
||||
} else {
|
||||
shareData = {
|
||||
title: baseTitle,
|
||||
text: fullMessage,
|
||||
url: window.location.href,
|
||||
};
|
||||
}
|
||||
|
||||
setIsGeneratingImage(false);
|
||||
|
||||
try {
|
||||
if (!navigator.canShare(shareData)) {
|
||||
toast.error(
|
||||
'Gagal membagikan checklist, coba dengan perangkat yang berbeda'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.share(shareData);
|
||||
toast.success('Checklist berhasil dibagikan');
|
||||
} catch (error) {
|
||||
toast.error('Gagal membagikan checklist');
|
||||
}
|
||||
};
|
||||
|
||||
const shareToWhatsAppHandler = async () => {
|
||||
const isMobile = isMobileDevice();
|
||||
setIsGeneratingImage(true);
|
||||
|
||||
const statusMsg = getStatusMessage();
|
||||
const category = header?.category || '';
|
||||
const message = encodeURIComponent(
|
||||
`Daily Checklist\n\nTanggal: ${formatDate(header?.date || '')}\nKandang: ${header?.kandang_name}\nKategori: ${CATEGORY_LABELS[category] || category}\nProgress: ${header?.progress_percent}%\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}\n\nLihat detail lengkap: ${window.location.href}`
|
||||
);
|
||||
|
||||
setIsGeneratingImage(false);
|
||||
|
||||
const whatsappUrl = isMobile
|
||||
? `https://wa.me/?text=${message}`
|
||||
: `https://web.whatsapp.com/send?text=${message}`;
|
||||
|
||||
window.open(whatsappUrl, '_blank');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
@@ -573,8 +687,8 @@ export function DetailDailyChecklistContent() {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
{/* Page Title with Back Button */}
|
||||
<div className='mb-6 flex items-center gap-4'>
|
||||
{/* Action Buttons */}
|
||||
<div className='mb-6 flex items-start sm:items-center justify-between gap-4 flex-wrap'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -584,37 +698,68 @@ export function DetailDailyChecklistContent() {
|
||||
<ArrowLeft className='w-4 h-4 mr-1' />
|
||||
Kembali
|
||||
</Button>
|
||||
<div className='flex-1'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Detail Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Lihat detail checklist harian
|
||||
</p>
|
||||
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{header.status === 'SUBMITTED' && (
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={actionLoading}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
<CheckCircle className='w-4 h-4 mr-2' />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<XCircle className='w-4 h-4 mr-2' />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={shareHandler}
|
||||
disabled={isGeneratingImage}
|
||||
className='border-gray-200'
|
||||
>
|
||||
<Share2 className='w-4 h-4 mr-1' />
|
||||
{!isGeneratingImage && 'Bagikan'}
|
||||
|
||||
{isGeneratingImage && 'Memuat...'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={shareToWhatsAppHandler}
|
||||
disabled={isGeneratingImage}
|
||||
className='border-gray-200'
|
||||
>
|
||||
<Icon icon='mdi:whatsapp' className='w-4 h-4 mr-1' />
|
||||
{!isGeneratingImage && 'Bagikan via WhatsApp'}
|
||||
|
||||
{isGeneratingImage && 'Memuat...'}
|
||||
</Button>
|
||||
</div>
|
||||
{header.status === 'SUBMITTED' && (
|
||||
<RequirePermission permissions='lti.daily_checklist.create'>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={handleApprove}
|
||||
disabled={actionLoading}
|
||||
className='bg-green-600 hover:bg-green-700 text-white'
|
||||
>
|
||||
<CheckCircle className='w-4 h-4 mr-2' />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReject}
|
||||
disabled={actionLoading}
|
||||
variant='destructive'
|
||||
className='bg-red-600 hover:bg-red-700 text-white'
|
||||
>
|
||||
<XCircle className='w-4 h-4 mr-2' />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Page Title */}
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Detail Daily Checklist
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Lihat detail checklist harian
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Header Info Card */}
|
||||
@@ -639,6 +784,18 @@ export function DetailDailyChecklistContent() {
|
||||
{CATEGORY_LABELS[header.category] || header.category}
|
||||
</p>
|
||||
</div>
|
||||
{header.category === 'empty_kandang' && (
|
||||
<div>
|
||||
<Label className='text-xs text-gray-500'>
|
||||
Tanggal Selesai Kandang Kosong
|
||||
</Label>
|
||||
<p className='text-sm font-medium text-gray-900 mt-1'>
|
||||
{header.empty_kandang_end_date
|
||||
? formatDate(header.empty_kandang_end_date)
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className='text-xs text-gray-500'>Status</Label>
|
||||
<div className='mt-1'>{getStatusBadge(header.status)}</div>
|
||||
|
||||
@@ -96,7 +96,10 @@ export function MasterEmployeeContent() {
|
||||
options: kandangOptions,
|
||||
loadMore: loadMoreKandang,
|
||||
isLoadingMore: isLoadingMoreKandang,
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
|
||||
order_by: 'asc',
|
||||
sort_by: 'name',
|
||||
});
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
@@ -217,7 +220,9 @@ export function MasterEmployeeContent() {
|
||||
'Error creating employee:',
|
||||
createEmployeeResponse.message
|
||||
);
|
||||
toast.error('Gagal menambahkan ABK');
|
||||
toast.error(
|
||||
'Gagal menambahkan ABK: ' + createEmployeeResponse.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +243,9 @@ export function MasterEmployeeContent() {
|
||||
'Error updating employee:',
|
||||
updateEmployeeResponse.message
|
||||
);
|
||||
toast.error('Gagal menambahkan ABK');
|
||||
toast.error(
|
||||
'Gagal memperbarui ABK: ' + updateEmployeeResponse.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,8 @@ import { cn } from '@/lib/helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useSelect } from '@/components/input/SelectInput';
|
||||
import { KandangApi, LocationApi } from '@/services/api/master-data';
|
||||
import { LocationApi } from '@/services/api/master-data';
|
||||
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
|
||||
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
|
||||
import { UserApi } from '@/services/api/user';
|
||||
|
||||
export function MasterKandangContent() {
|
||||
@@ -108,12 +107,6 @@ export function MasterKandangContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
options: kandangOptions,
|
||||
isLoadingMore: isLoadingKandangOptionsMore,
|
||||
loadMore: loadMoreKandang,
|
||||
} = useSelect(KandangApi.basePath, 'id', 'name');
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kandangToDelete, setKandangToDelete] = useState<number | null>(null);
|
||||
@@ -375,7 +368,9 @@ export function MasterKandangContent() {
|
||||
name='search'
|
||||
placeholder='Cari kandang...'
|
||||
value={tableFilterState.search}
|
||||
onChange={(e) => updateFilter('search', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateFilter('search', e.target.value, true)
|
||||
}
|
||||
className={{
|
||||
wrapper: 'w-full sm:w-[280px] border-gray-200',
|
||||
inputWrapper: 'px-3 py-2 h-fit rounded-md',
|
||||
@@ -390,7 +385,11 @@ export function MasterKandangContent() {
|
||||
<Select
|
||||
value={tableFilterState.location_id}
|
||||
onValueChange={(value) =>
|
||||
updateFilter('location_id', value === 'all' ? '' : value)
|
||||
updateFilter(
|
||||
'location_id',
|
||||
value === 'all' ? '' : value,
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[180px] border-gray-200'>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent } from '@/figma-make/components/base/card';
|
||||
import { toast } from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { SystemSettingsApi } from '@/services/api/system-settings';
|
||||
import { SystemSetting } from '@/types/api/system-settings/system-setting';
|
||||
|
||||
const ALLOW_NEGATIVE_PAKAN_OVK_KEY = 'allow_negative_pakan_ovk';
|
||||
|
||||
function SettingToggle({
|
||||
setting,
|
||||
onToggle,
|
||||
loading,
|
||||
}: {
|
||||
setting: SystemSetting;
|
||||
onToggle: (key: string, currentValue: boolean) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const isEnabled = setting.value === 'true';
|
||||
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-4 py-5'>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium text-gray-900'>
|
||||
{setting.key === ALLOW_NEGATIVE_PAKAN_OVK_KEY
|
||||
? 'Mode Migrasi PAKAN & OVK'
|
||||
: setting.key}
|
||||
</p>
|
||||
{setting.description && (
|
||||
<p className='text-sm text-gray-500 mt-0.5'>{setting.description}</p>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center mt-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isEnabled
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isEnabled ? 'Aktif' : 'Nonaktif'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
role='switch'
|
||||
aria-checked={isEnabled}
|
||||
disabled={loading}
|
||||
onClick={() => onToggle(setting.key, isEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-[#0069e0] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isEnabled ? 'bg-[#0069e0]' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
isEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemConfigContent() {
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: settingsResponse,
|
||||
isLoading,
|
||||
mutate: refreshSettings,
|
||||
} = useSWR(SystemSettingsApi.basePath, SystemSettingsApi.getAllFetcher, {
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const handleToggle = async (key: string, currentValue: boolean) => {
|
||||
if (key !== ALLOW_NEGATIVE_PAKAN_OVK_KEY) return;
|
||||
|
||||
setToggling(key);
|
||||
try {
|
||||
const res = await SystemSettingsApi.setAllowNegativePakanOvk({
|
||||
value: !currentValue,
|
||||
});
|
||||
|
||||
if (isResponseError(res)) {
|
||||
toast.error(res.message || 'Gagal mengubah pengaturan');
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshSettings();
|
||||
toast.success(
|
||||
!currentValue
|
||||
? 'Mode migrasi PAKAN & OVK diaktifkan'
|
||||
: 'Mode migrasi PAKAN & OVK dinonaktifkan'
|
||||
);
|
||||
} catch {
|
||||
toast.error('Terjadi kesalahan saat mengubah pengaturan');
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const settings = isResponseSuccess(settingsResponse)
|
||||
? settingsResponse.data
|
||||
: [];
|
||||
|
||||
if (isLoading && !settingsResponse) {
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Konfigurasi Sistem
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data •{' '}
|
||||
<span className='text-[#0069e0]'>Konfigurasi Sistem</span>
|
||||
</p>
|
||||
</div>
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-12 text-center text-gray-500'>
|
||||
Memuat data...
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-screen'>
|
||||
<div className='p-6'>
|
||||
<div className='mb-6'>
|
||||
<h1 className='text-2xl font-semibold text-gray-900'>
|
||||
Konfigurasi Sistem
|
||||
</h1>
|
||||
<p className='text-sm text-gray-600 mt-1'>
|
||||
Master Data •{' '}
|
||||
<span className='text-[#0069e0]'>Konfigurasi Sistem</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className='border-gray-200/60 shadow-sm rounded-xl bg-white'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='px-6 py-4 border-b border-gray-200/60'>
|
||||
<h2 className='text-base font-semibold text-gray-800'>
|
||||
Pengaturan Global
|
||||
</h2>
|
||||
<p className='text-sm text-gray-500 mt-0.5'>
|
||||
Pengaturan ini berlaku untuk seluruh sistem.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='px-6 divide-y divide-gray-200/60'>
|
||||
{settings.length === 0 ? (
|
||||
<p className='py-10 text-center text-sm text-gray-500'>
|
||||
Tidak ada pengaturan tersedia.
|
||||
</p>
|
||||
) : (
|
||||
settings.map((setting) => (
|
||||
<SettingToggle
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
onToggle={handleToggle}
|
||||
loading={toggling === setting.key}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,6 +137,8 @@ export function DailyChecklistReportsContent() {
|
||||
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', {
|
||||
area_id: tableFilterState.area_id,
|
||||
location_id: tableFilterState.location_id,
|
||||
order_by: 'asc',
|
||||
sort_by: 'name',
|
||||
});
|
||||
|
||||
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
@@ -159,17 +161,24 @@ export function DailyChecklistReportsContent() {
|
||||
}
|
||||
);
|
||||
|
||||
const { options: employeeOptions } = useSelect(
|
||||
EmployeeApi.basePath,
|
||||
'id',
|
||||
'name',
|
||||
'search',
|
||||
{
|
||||
page: '1',
|
||||
limit: '500',
|
||||
kandang_id: tableFilterState.kandang_id,
|
||||
const {
|
||||
options: employeeOptions,
|
||||
loadMore: loadMoreEmployee,
|
||||
isLoadingMore: isLoadingMoreEmployee,
|
||||
} = useSelect(EmployeeApi.basePath, 'id', 'name', 'search', {
|
||||
order_by: 'asc',
|
||||
sort_by: 'name',
|
||||
kandang_id: tableFilterState.kandang_id,
|
||||
});
|
||||
|
||||
const handleEmployeeScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
if (target.scrollHeight - target.scrollTop <= target.clientHeight + 10) {
|
||||
if (!isLoadingMoreEmployee) {
|
||||
loadMoreEmployee();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const currentMonthMaxDay = new Date(
|
||||
Number(tableFilterState.tahun),
|
||||
@@ -493,7 +502,7 @@ export function DailyChecklistReportsContent() {
|
||||
>
|
||||
<SelectValue placeholder='Semua ABK' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent onScroll={handleEmployeeScroll}>
|
||||
<SelectItem value='ALL'>Semua ABK</SelectItem>
|
||||
{employeeOptions.map((employee) => (
|
||||
<SelectItem
|
||||
@@ -503,6 +512,11 @@ export function DailyChecklistReportsContent() {
|
||||
{employee.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isLoadingMoreEmployee && (
|
||||
<div className='flex justify-center p-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin text-gray-500' />
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BaseApiResponse,
|
||||
ErrorApiResponse,
|
||||
@@ -15,3 +16,40 @@ export const isResponseError = <T>(
|
||||
): res is ErrorApiResponse => {
|
||||
return res?.status === 'error';
|
||||
};
|
||||
|
||||
export const getErrorMessage = async (
|
||||
error: unknown,
|
||||
fallbackMessage: string
|
||||
) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const parsed = JSON.parse(await responseData.text()) as {
|
||||
message?: string;
|
||||
};
|
||||
return parsed.message || fallbackMessage;
|
||||
} catch {
|
||||
return fallbackMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
responseData &&
|
||||
typeof responseData === 'object' &&
|
||||
'message' in responseData &&
|
||||
typeof responseData.message === 'string'
|
||||
) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return error.message || fallbackMessage;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
type SearchParamsLike = {
|
||||
get: (name: string) => string | null;
|
||||
};
|
||||
|
||||
const EXPENSE_LIST_PATH = '/expense';
|
||||
|
||||
export const getExpenseListReturnTo = (searchParams: SearchParamsLike) => {
|
||||
const existingReturnTo = searchParams.get('returnTo');
|
||||
|
||||
if (existingReturnTo?.startsWith(EXPENSE_LIST_PATH)) {
|
||||
return existingReturnTo;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const page = searchParams.get('page');
|
||||
const limit = searchParams.get('limit');
|
||||
|
||||
if (page) params.set('page', page);
|
||||
if (limit) params.set('limit', limit);
|
||||
|
||||
const queryString = params.toString();
|
||||
|
||||
return queryString
|
||||
? `${EXPENSE_LIST_PATH}?${queryString}`
|
||||
: EXPENSE_LIST_PATH;
|
||||
};
|
||||
|
||||
export const buildExpenseActionHref = (
|
||||
path: string,
|
||||
expenseId: number | string,
|
||||
searchParams: SearchParamsLike
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
expenseId: String(expenseId),
|
||||
returnTo: getExpenseListReturnTo(searchParams),
|
||||
});
|
||||
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DailyChecklist,
|
||||
DailyChecklistReport,
|
||||
DetailDailyChecklist,
|
||||
UpdateDailyChecklistPayload,
|
||||
} from '@/types/api/daily-checklist/daily-checklist';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import { toast } from 'sonner';
|
||||
@@ -16,12 +17,39 @@ import { toast } from 'sonner';
|
||||
export class DailyChecklistApiService extends BaseApiService<
|
||||
DailyChecklist,
|
||||
CreateDailyChecklistPayload,
|
||||
unknown
|
||||
UpdateDailyChecklistPayload
|
||||
> {
|
||||
constructor(basePath: string = '/daily-checklists') {
|
||||
super(basePath);
|
||||
}
|
||||
|
||||
async update(id: number, payload: UpdateDailyChecklistPayload) {
|
||||
const isFormData =
|
||||
typeof FormData !== 'undefined' && payload instanceof FormData;
|
||||
try {
|
||||
const updatePath = `${this.basePath}/${id}`;
|
||||
|
||||
const headers = isFormData
|
||||
? { ...(this.header ?? {}) }
|
||||
: { 'Content-Type': 'application/json', ...(this.header ?? {}) };
|
||||
|
||||
const updateRes = await httpClient<BaseApiResponse<DailyChecklist>>(
|
||||
updatePath,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
return updateRes;
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError<BaseApiResponse<DailyChecklist>>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async getOneDailyChecklist(id: string) {
|
||||
try {
|
||||
const getOneDailyChecklistPath = `${this.basePath}/relation/${id}`;
|
||||
@@ -192,6 +220,29 @@ export class DailyChecklistApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApprove(ids: string[]) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('ids', ids.join(','));
|
||||
formData.append('status', 'APPROVED');
|
||||
formData.append('reject_reason', '');
|
||||
|
||||
const approvePath = `${this.basePath}/bulk-update`;
|
||||
const approveRes = await httpClient<BaseApiResponse>(approvePath, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return approveRes;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async reject(id: string, rejectReason: string) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -215,6 +266,29 @@ export class DailyChecklistApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkReject(ids: string[], rejectReason: string) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('ids', ids.join(','));
|
||||
formData.append('status', 'REJECTED');
|
||||
formData.append('reject_reason', rejectReason);
|
||||
|
||||
const rejectPath = `${this.basePath}/bulk-update`;
|
||||
const rejectRes = await httpClient<BaseApiResponse>(rejectPath, {
|
||||
method: 'PATCH',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
return rejectRes;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
id: number,
|
||||
status: string,
|
||||
|
||||
@@ -2,12 +2,14 @@ import axios from 'axios';
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import { BaseApiResponse, GroupedApprovals } from '@/types/api/api-general';
|
||||
import {
|
||||
BulkApproveExpensePayload,
|
||||
CreateExpensePayload,
|
||||
CreateExpenseRealizationPayload,
|
||||
Expense,
|
||||
UpdateExpensePayload,
|
||||
} from '@/types/api/expense';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
|
||||
export class ExpenseApiService extends BaseApiService<
|
||||
Expense,
|
||||
@@ -330,6 +332,65 @@ export class ExpenseApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApproveToStatus(
|
||||
payload: BulkApproveExpensePayload
|
||||
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
|
||||
try {
|
||||
return await httpClient<BaseApiResponse<Expense | Expense[]>>(
|
||||
`${this.basePath}/approvals/bulk`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse<Expense | Expense[]>>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async bulkApprovals(
|
||||
ids: number[],
|
||||
status: BulkApproveExpensePayload['status'] | 'SELESAI',
|
||||
date?: string,
|
||||
notes?: string
|
||||
): Promise<BaseApiResponse<Expense | Expense[]> | undefined> {
|
||||
if (status === 'SELESAI') {
|
||||
const responses = await Promise.all(ids.map((id) => this.complete(id)));
|
||||
const failedResponse = responses.find(
|
||||
(response) => response?.status !== 'success'
|
||||
);
|
||||
|
||||
if (failedResponse) {
|
||||
return failedResponse;
|
||||
}
|
||||
|
||||
const completedExpenses = responses.flatMap((response) =>
|
||||
response?.status === 'success' ? [response.data] : []
|
||||
);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
status: 'success',
|
||||
message:
|
||||
completedExpenses.length === 1
|
||||
? 'Submit expense approval successfully'
|
||||
: 'Submit expense approvals successfully',
|
||||
data: completedExpenses,
|
||||
};
|
||||
}
|
||||
|
||||
return this.bulkApproveToStatus({
|
||||
approvable_ids: ids,
|
||||
status,
|
||||
date: date || undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async rejectHeadArea(
|
||||
id: number,
|
||||
notes?: string
|
||||
@@ -511,6 +572,25 @@ export class ExpenseApiService extends BaseApiService<
|
||||
}
|
||||
}
|
||||
|
||||
async setExpensePaidOff(id: number) {
|
||||
try {
|
||||
const res = await httpClient<BaseApiResponse<Expense>>(
|
||||
`${this.basePath}/${id}/pay`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError<BaseApiResponse<Expense>>(error)) {
|
||||
return error.response?.data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExpenseRequestDocument(
|
||||
expenseId: number,
|
||||
documentId: number
|
||||
@@ -646,6 +726,60 @@ export class ExpenseApiService extends BaseApiService<
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
async exportToExcel(initialQueryString: string) {
|
||||
const params = new URLSearchParams(initialQueryString);
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'all');
|
||||
params.set('page', '1');
|
||||
params.set('limit', '99999999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `BOP-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
async exportInputProgressToExcel(startDate: string, endDate: string) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('export', 'excel');
|
||||
params.set('type', 'progress');
|
||||
params.set('start_date', formatDate(startDate, 'YYYY-MM-DD'));
|
||||
params.set('end_date', formatDate(endDate, 'YYYY-MM-DD'));
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `input-progres-BOP-${formatDate(startDate, 'DD-MM-YYYY')}-ke-${formatDate(endDate, 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export const ExpenseApi = new ExpenseApiService('/expenses');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user