Compare commits

..

18 Commits

Author SHA1 Message Date
rstubryan 584b495e4b refactor(FE): Fix missing dependency in useEffect hook 2026-03-07 12:01:46 +07:00
rstubryan c8e76a8558 refactor(FE): Refactor ExpensePDF component for improved readability 2026-03-07 10:14:10 +07:00
rstubryan a468a5948b refactor(FE): Refactor useEffect hooks and loadAssignments function 2026-03-07 09:34:55 +07:00
rstubryan e05e1a4121 chore(FE): Fix missing dependency in useEffect dependency array 2026-03-07 09:20:14 +07:00
rstubryan e3b86e3033 refactor(FE): Add omit utility and refactor clearTabActions to use
it
2026-03-07 09:19:11 +07:00
rstubryan d467c56ea6 refactor(FE): Refactor form reset logic to use useCallback with
dependencies
2026-03-06 15:03:37 +07:00
rstubryan 784d9f26ab refactor(FE): Refactor fetchChecklistDetail with useCallback 2026-03-06 15:00:33 +07:00
rstubryan 978ef764ea refactor(FE): Refactor productsClickHandler to be defined inline 2026-03-06 14:45:27 +07:00
rstubryan 928136ff18 refactor(FE): Refactor handleViewUniformityDetails to inline definition 2026-03-06 14:29:21 +07:00
rstubryan 79567e4f1b refactor(FE): Remove unnecessary dependencies from useMemo in table
columns
2026-03-06 14:07:24 +07:00
rstubryan 633deece21 refactor(FE): Fix dependency array in ExpenseRealizationForm useCallback 2026-03-06 14:05:11 +07:00
rstubryan 46483af4c2 refactor(FE): Refactor formik field methods to use destructured helpers 2026-03-06 13:53:15 +07:00
rstubryan c2653e5068 Merge branch 'development' of gitlab.com:mbugroup/lti-web-client into fix/cleanup-warning 2026-03-06 13:41:30 +07:00
rstubryan 8569bda7d6 refactor(FE): Refactor afterSubmit calls to improve readability 2026-03-06 13:41:29 +07:00
rstubryan 7da63dc542 refactor(FE): Prevent duplicate initial value loading in form modals 2026-03-06 13:37:35 +07:00
rstubryan aeceef4361 refactor(FE): Refactor handleBlurField to use useCallback 2026-03-06 13:35:10 +07:00
rstubryan ff6955be54 refactor(FE): Remove unused useSearchParams and related code 2026-03-06 13:33:46 +07:00
rstubryan eccab314b3 refactor(FE): Fix missing dependencies in MarketingTable useMemo 2026-03-06 11:23:01 +07:00
181 changed files with 5182 additions and 14030 deletions
-3
View File
@@ -45,6 +45,3 @@ next-env.d.ts
# claude # claude
.claude .claude
# rtk
rtk.exe
+3 -50
View File
@@ -15,7 +15,7 @@ default:
# ========================================================== # ==========================================================
.build_template: &build_template .build_template: &build_template
stage: build stage: build
image: public.ecr.aws/docker/library/node:20-alpine image: node:20-alpine
cache: cache:
key: npm-cache key: npm-cache
paths: paths:
@@ -30,10 +30,6 @@ default:
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL" - echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_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_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..." - echo "Building Next.js static export..."
- npx next build - npx next build
- | - |
@@ -45,11 +41,7 @@ default:
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", "built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL", "NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_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 EOF
artifacts: artifacts:
@@ -64,7 +56,7 @@ default:
.deploy_template: &deploy_template .deploy_template: &deploy_template
stage: deploy stage: deploy
image: image:
name: public.ecr.aws/aws-cli/aws-cli:latest name: amazon/aws-cli:latest
entrypoint: ['/bin/sh', '-c'] entrypoint: ['/bin/sh', '-c']
script: script:
- set -e - set -e
@@ -150,10 +142,6 @@ build:dev:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id' 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_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' 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:dev:
<<: *deploy_template <<: *deploy_template
@@ -182,9 +170,6 @@ build:staging:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id' 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_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' 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:staging:
<<: *deploy_template <<: *deploy_template
@@ -198,35 +183,3 @@ deploy:staging:
environment: environment:
name: staging name: staging
url: https://stg-lti-erp.mbugroup.id url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
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
needs: ['build:production']
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
variables:
S3_BUCKET: 'production-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
environment:
name: staging
url: https://lti-erp.mbugroup.id
+1 -2
View File
@@ -1,4 +1,3 @@
npm run format npm run format
npm run lint npm run lint
npm run typecheck npx tsc --noEmit
git add .
-13
View File
@@ -1,13 +0,0 @@
# 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"
-414
View File
@@ -1,414 +0,0 @@
# LTI Web Client
Next.js 15 (App Router) + React 19 + TypeScript front-end for the LTI ERP system.
## Tech stack
- **Framework:** Next.js 15.5 (App Router, Turbopack)
- **UI:** React 19, Tailwind CSS v4, Radix UI, daisyUI, lucide-react
- **State:** zustand
- **Forms:** Formik + Yup, react-hook-form
- **Data fetching:** axios + SWR (custom `httpClient` / `httpClientFetcher` in `src/services/http`)
- **Tables:** @tanstack/react-table
- **Reporting:** @react-pdf/renderer, jspdf, exceljs, xlsx, recharts
## Scripts
- `npm run dev` — lint + dev server (Turbopack)
- `npm run build` — production build
- `npm run lint` — ESLint
- `npm run typecheck``next typegen && tsc --noEmit`
- `npm run format` — Prettier
- `npm run pre-commit` — format + lint + typecheck + build (Husky pre-commit hook)
## Project structure
```
src/
app/ # Next.js App Router routes (one folder per feature)
components/
pages/{feature}/ # Page-specific components (mirrors src/app)
helper/ # Cross-cutting helpers (e.g. SuspenseHelper)
ui/ # Shared UI primitives
services/
api/ # API service classes (extend BaseApiService)
http/ # httpClient / httpClientFetcher
hooks/ # Service-level hooks
stores/ # zustand stores grouped by domain
types/api/ # Request/response types per feature
lib/ # Shared helpers (api-helper, formik-helper, utils, validation, …)
config/, styles/
```
## Feature development standard
**Always follow this order when adding a new feature.** This is a team convention — deviating creates churn in code review.
1. **Types** — Define payload and response types in `src/types/api/{feature}` (or `{feature}.d.ts` for small features).
2. **API service** — Add `src/services/api/{feature}.ts` exporting a class that extends `BaseApiService<T, CreatePayload, UpdatePayload>` from [src/services/api/base.ts](src/services/api/base.ts). Use a subfolder (e.g. `src/services/api/daily-checklist/`) when the feature has multiple resource classes.
3. **Page** — Create the route under `src/app/{feature}` and a matching `src/components/pages/{feature}` folder for its components.
4. **Component slicing** — Break the page UI into components inside `src/components/pages/{feature}`.
5. **Wire up the API** — Consume the service class from step 2 inside the page/components (often via SWR).
6. **Detail layout** — When a route reads URL params via `useSearchParams` (e.g. `/feature/detail?id=123`), add `src/app/{feature}/detail/layout.tsx` that wraps `children` in `<SuspenseHelper>` from `@/components/helper/SuspenseHelper`.
7. **Shared state** — Use zustand stores in `src/stores/{domain}` when state must cross component boundaries.
8. **Helpers** — Reuse from [src/lib](src/lib) first (`api-helper.ts`, `formik-helper.ts`, `utils/`, `validation/`, etc.). Add new helpers there.
### Reference implementations
`closing`, `finance`, `expense`, `production`, `inventory`, `marketing`, `master-data`, `purchase`, `report`, `daily-checklist`, `dashboard` — all live in both `src/app/{feature}` and `src/components/pages/{feature}` and follow the standard above.
## Conventions
- Path alias `@/` maps to `src/`.
- Detail pages that read `useSearchParams` MUST be wrapped in `<SuspenseHelper>` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern).
- API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components.
- Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`.
## Table filter persistence pattern
Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes.
**Three core principles (apply to all table components):**
1. **Set formik initialValues from tableFilterState** (not hardcoded defaults)
- Ensures the filter modal displays currently active filters when opened
- Initialize directly from persisted state: `location: tableFilterState.locationFilter`
2. **Pass `true` as last parameter to updateFilter calls**
- `updateFilter('fieldName', value, true)` immediately persists to localStorage
- Resets pagination to page 1 when filters change (via SWR revalidation)
- Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function**
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
- Call `formik.resetForm({ values: { ...defaults } })`
- Close the modal at the end
- Attach to both button `onClick` and form `onReset` handler
**Optimization: Avoid useCallback for simple handlers**
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
```tsx
// ✅ Good: Simple handler without useCallback
const handleFilterChange = (val) => setFieldValue('location', val);
// ❌ Avoid: Unnecessary useCallback overhead
const handleFilterChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
```
**Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
```tsx
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined
},
paramMap: {
page: 'page',
pageSize: 'limit',
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik<KandangFilterType>({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
...
});
// Handlers store the complete OptionType, not just the ID
const handleFilterLocationChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
```
**Apply this pattern to:**
- Any data table component across any module that needs persistent filters
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
- Whenever users' filter/search/pagination choices should survive page refreshes
**Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
## Server-side sorting pattern
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
**Four-part wiring:**
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
```ts
initial: { sort_by: '', order_by: '' }
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
```
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
```ts
useEffect(() => {
if (sorting.length > 0) {
updateFilter('sort_by', sorting[0].id, true);
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '');
updateFilter('order_by', '');
}
}, [sorting]);
```
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
```tsx
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
```
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
## Server-side file export pattern
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
### Service method (in `src/services/api/{feature}.ts`)
```ts
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
}
```
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
- Add one method per format; keep them side-by-side in the same service class.
### Component handler (in the page/tab component)
```ts
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const params = new URLSearchParams();
if (filterParams.foo) params.set('foo', filterParams.foo);
// ... map all active filter params ...
await FeatureApi.exportToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, searchValue]);
```
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (60-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk go test # Go test failures only (90%)
rtk jest # Jest failures only (99.5%)
rtk vitest # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk pytest # Python test failures only (90%)
rtk rake test # Ruby test failures only (90%)
rtk rspec # RSpec test failures only (60%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
| ---------------- | ------------------------------ | --------------- |
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+2 -2
View File
@@ -1,4 +1,4 @@
FROM public.ecr.aws/docker/library/node:20-alpine FROM node:20-alpine
RUN apk add --no-cache git bash build-base curl RUN apk add --no-cache git bash build-base curl
@@ -22,4 +22,4 @@ RUN mkdir -p .next/server/app/_next && \
EXPOSE 3000 EXPOSE 3000
CMD ["npx", "serve", ".next/server/app", "-l", "3000"] CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
+1 -3
View File
@@ -7,10 +7,8 @@
"build": "next build --turbopack", "build": "next build --turbopack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "next typegen && tsc --noEmit",
"prepare": "husky", "prepare": "husky",
"format": "prettier --write .", "format": "prettier --write ."
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.1", "@react-pdf/renderer": "^4.3.1",
@@ -1,11 +0,0 @@
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
const MasterKandangPage = () => {
return (
<section className='w-full'>
<MasterKandangContent />
</section>
);
};
export default MasterKandangPage;
+2 -2
View File
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
const expenseId = searchParams.get('expenseId'); const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR( const { data: expense, isLoading: isLoadingExpense } = useSWR(
['expense-detail', expenseId], expenseId,
([_, id]) => ExpenseApi.getSingle(Number(id)) (id: number) => ExpenseApi.getSingle(id)
); );
if (!expenseId) { if (!expenseId) {
@@ -1,11 +0,0 @@
import { SystemConfigContent } from '@/figma-make/components/pages/master-data/system-config/SystemConfigContent';
const SystemConfigPage = () => {
return (
<section className='w-full'>
<SystemConfigContent />
</section>
);
};
export default SystemConfigPage;
@@ -11,13 +11,10 @@ const RecordingEdit = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId'); const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingDetailKey, recordingId,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id)) (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+2 -5
View File
@@ -11,13 +11,10 @@ const RecordingDetail = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId'); const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR( const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingDetailKey, recordingId,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id)) (id: string) => RecordingApi.getSingle(parseInt(id))
); );
if (!recordingId) { if (!recordingId) {
+3 -3
View File
@@ -51,7 +51,7 @@ const Button = ({
return ( return (
<> <>
{(!href || (href && disabled)) && ( {!href && (
<button <button
{...props} {...props}
type={type} type={type}
@@ -68,9 +68,9 @@ const Button = ({
</button> </button>
)} )}
{href && !disabled && ( {href && (
<Link <Link
href={href} href={disabled ? '#' : href}
target={target} target={target}
rel={rel} rel={rel}
aria-disabled={disabled} aria-disabled={disabled}
+1 -1
View File
@@ -226,7 +226,7 @@ const Pagination = ({
const PageInfo = () => ( const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'> <span className='text-nowrap text-sm font-medium text-base-content/50'>
Total Item: {totalItems} | Page {currentPage} of {totalPages} Page {currentPage} of {totalPages}
</span> </span>
); );
-1
View File
@@ -173,7 +173,6 @@ const Table = <TData extends object>({
const tableOptions: TableOptions<TData> = { const tableOptions: TableOptions<TData> = {
columns, columns,
data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion data: isLoading ? (DUMMY_SKELETON_DATA as TData[]) : data, // Type assertion
defaultColumn: { sortDescFirst: false },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
+1 -3
View File
@@ -35,9 +35,7 @@ const NumberInput = ({
| undefined; | undefined;
if (newChangeEvent) { if (newChangeEvent) {
newChangeEvent.target.value = parseFloat( newChangeEvent.target.value = numberFormatValues.value;
numberFormatValues.value
) as unknown as string;
onChange?.(newChangeEvent); onChange?.(newChangeEvent);
} }
+17 -25
View File
@@ -24,8 +24,8 @@ import {
} from '@/types/api/api-general'; } from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType<T = string | number> { export interface OptionType {
value: T; value: string | number;
label: string; label: string;
className?: string; className?: string;
labelClassName?: string; labelClassName?: string;
@@ -523,7 +523,7 @@ const useSelect = <T,>(
const qs = new URLSearchParams({ const qs = new URLSearchParams({
...(params ?? {}), ...(params ?? {}),
[searchKey ? searchKey : 'search']: inputValue ?? '', [searchKey]: inputValue ?? '',
[pageKey]: String(pageIndex + 1), [pageKey]: String(pageIndex + 1),
[limitKey]: String(limit), [limitKey]: String(limit),
}).toString(); }).toString();
@@ -566,31 +566,23 @@ const useSelect = <T,>(
setSize(size + 1); setSize(size + 1);
}; };
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0; const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => { if (isResponseSuccess(pages?.[latestPagesIndex])) {
let successData: SuccessApiResponse<T[]> | undefined = undefined; formattedSuccessRawData = {
let errorData: ErrorApiResponse | undefined = undefined; ...pages?.[latestPagesIndex],
data:
if (isResponseSuccess(pages?.[latestPagesIndex])) { pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
successData = { [],
...pages![latestPagesIndex],
data:
pages?.flatMap((page) =>
isResponseSuccess(page) ? page.data : []
) ?? [],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
errorData = pages![latestPagesIndex];
}
return {
formattedSuccessRawData: successData,
formattedErrorRawData: errorData,
}; };
}, [pages, latestPagesIndex]); }
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return { return {
inputValue, inputValue,
@@ -69,7 +69,6 @@ const ConfirmationModalWithNotes: React.FC<ConfirmationModalWithNotesProps> = ({
secondaryButton={ secondaryButton={
secondaryButton secondaryButton
? { ? {
...secondaryButton,
text: secondaryButton?.text ?? 'Tidak', text: secondaryButton?.text ?? 'Tidak',
onClick: (e) => { onClick: (e) => {
if (secondaryButton && secondaryButton?.onClick) { if (secondaryButton && secondaryButton?.onClick) {
@@ -112,11 +112,12 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
kandangData={kandangData} kandangData={kandangData}
/> />
<ClosingKandangList {!kandangData && (
initialValue={initialValue} <ClosingKandangList
projectData={projectData} initialValue={initialValue}
selectedKandangId={kandangData?.id} projectData={projectData}
/> />
)}
<Tabs <Tabs
activeTabId={activeTabId} activeTabId={activeTabId}
@@ -5,11 +5,9 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
const ClosingKandangList = ({ const ClosingKandangList = ({
initialValue, initialValue,
projectData, projectData,
selectedKandangId,
}: { }: {
initialValue?: ClosingGeneralInformation; initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock; projectData?: ProjectFlock;
selectedKandangId?: number;
}) => { }) => {
return ( return (
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'> <div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
@@ -24,9 +22,6 @@ const ClosingKandangList = ({
variant='outline' variant='outline'
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm' className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`} href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
disabled={
selectedKandangId === kandang.project_flock_kandang_id
}
> >
{kandang.name} {kandang.name}
</Button> </Button>
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
{ {
id: 'kandang', id: 'kandang',
accessorKey: 'kandang', accessorKey: 'kandang',
header: 'Kandang Atribusi', header: 'Kandang',
cell: (props) => { cell: (props) => {
const kandang = props.getValue() as Kandang; const kandang = props.getValue() as Kandang;
return kandang?.name || '-'; return kandang?.name || '-';
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
}, },
{ {
accessorKey: 'source_warehouse', accessorKey: 'source_warehouse',
header: 'Gudang Asal (Fisik)', header: 'Gudang Asal',
}, },
{ {
accessorKey: 'destination_warehouse', accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan (Fisik)', header: 'Gudang Tujuan',
}, },
{ {
accessorKey: 'quantity', accessorKey: 'quantity',
@@ -9,11 +9,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard'; import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { import { ProjectFlockApi } from '@/services/api/production';
ProjectFlockApi, import { KandangApi, LocationApi } from '@/services/api/master-data';
ProjectFlockKandangApi,
} from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF'; import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import { import {
DashboardFilterType, DashboardFilterType,
@@ -25,7 +22,10 @@ import DashboardExportCharts, {
DashboardExportChartsRef, DashboardExportChartsRef,
} from '@/components/pages/dashboard/export/DashboardExportCharts'; } from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput'; import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { DashboardMeta } from '@/types/api/dashboard/dashboard'; import {
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats'; import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
@@ -42,8 +42,6 @@ import { cn } from '@/lib/helper';
import DashboardExportStats, { import DashboardExportStats, {
DashboardExportStatsRef, DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats'; } from '@/components/pages/dashboard/export/DashboardExportStats';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
// Helper function to normalize values to array // Helper function to normalize values to array
const normalizeToArray = ( const normalizeToArray = (
@@ -70,6 +68,7 @@ const DashboardProduction = () => {
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>( const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW' (filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
); );
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>( const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location) normalizeToArray(filterValues.location)
); );
@@ -81,29 +80,9 @@ const DashboardProduction = () => {
const { const {
data: dashboardProductionResponse, data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData, isLoading: isLoadingDashboardProductionData,
} = useSWR( mutate: refreshDashboardProductionData,
[ } = useSWR(endpointUrl, () =>
'dashboard-production', DashboardApi.getDashboardProductionFetcher(endpointUrl)
filterValues.startDate ?? '',
filterValues.endDate ?? '',
filterValues.analysisMode ?? 'OVERVIEW',
normalizeToArray(filterValues.location).toString(),
normalizeToArray(filterValues.flock).toString(),
normalizeToArray(filterValues.kandang).toString(),
filterValues.comparisonType ?? '',
],
() =>
DashboardApi.getDashboardProductionFetcher({
start_date: filterValues.startDate || '',
end_date: filterValues.endDate || '',
analysis_mode:
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
'OVERVIEW',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType || '',
})
); );
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse) const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
@@ -116,23 +95,23 @@ const DashboardProduction = () => {
options: flockOptions, options: flockOptions,
isLoadingOptions: isLoadingFlockOptions, isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock, loadMore: loadMoreFlock,
} = useSelect<ProjectFlock>( } = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
ProjectFlockApi.basePath, location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
'id', });
'flock_name',
'search',
{
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
}
);
const { const {
setInputValue: setInputValueLocation, setInputValue: setInputValueLocation,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation, loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name'); } = useSelect(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [ const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' }, { value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' }, { value: 'FLOCK', label: 'Flock' },
@@ -156,43 +135,68 @@ const DashboardProduction = () => {
enableReinitialize: true, enableReinitialize: true,
validationSchema: getDashboardFilterSchema(analysisMode), validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => { onSubmit: (values) => {
// Save filter values to store
setFilterValues(values); setFilterValues(values);
filterModal.closeModal();
handleApplyFilter({
start_date: values.startDate || '',
end_date: values.endDate || '',
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(values.location),
flock_ids: normalizeToArray(values.flock),
kandang_ids: normalizeToArray(values.kandang),
comparison_type: values.comparisonType,
});
}, },
}); });
const { resetForm } = formik; const { resetForm } = formik;
const selectedLocationValues = normalizeToArray(formik.values.location);
const selectedFlockValues = normalizeToArray(formik.values.flock);
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'kandang_id',
'kandang.name',
'search',
{
location_id:
selectedLocationValues.length > 0
? selectedLocationValues.toString()
: '',
project_flock_id:
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
}
);
const handleResetFilter = useCallback(() => { const handleResetFilter = useCallback(() => {
resetForm(); resetForm();
resetFilterValues(); // Clear stored filter values resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW'); setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]); setSelectedLocationIds([]);
filterModal.closeModal(); }, [resetForm, resetFilterValues]);
}, [filterModal, resetForm, resetFilterValues]);
const handleApplyFilter = useCallback(
(values: DashboardFilter) => {
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type)
params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
filterModal.closeModal();
refreshDashboardProductionData();
},
[filterModal, refreshDashboardProductionData]
);
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues, handleApplyFilter]);
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
@@ -264,6 +268,14 @@ const DashboardProduction = () => {
}; };
}, [clearNavbarActions]); }, [clearNavbarActions]);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return ( return (
<> <>
<section className='w-full p-3 space-y-3'> <section className='w-full p-3 space-y-3'>
@@ -315,15 +327,9 @@ const DashboardProduction = () => {
</div> </div>
{/* Dashboard Stats */} {/* Dashboard Stats */}
<div> <div>
{isLoadingDashboardProductionData ? ( <DashboardStats
<div className='w-full min-h-screen flex items-center justify-center'> data={dashboardProductionData?.statistics_data ?? []}
<span className='loading loading-spinner loading-xl'></span> />
</div>
) : (
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
)}
</div> </div>
{/* Use DashboardLineChart component or skeleton */} {/* Use DashboardLineChart component or skeleton */}
@@ -531,7 +537,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
@@ -568,7 +573,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -600,7 +604,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
@@ -640,7 +643,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -667,7 +669,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
</> </>
@@ -706,7 +707,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
) : ( ) : (
<SelectInputRadio <SelectInputRadio
@@ -733,7 +733,6 @@ const DashboardProduction = () => {
className={{ className={{
select: 'rounded-lg text-sm border-base-content/10', select: 'rounded-lg text-sm border-base-content/10',
}} }}
isClearable={true}
/> />
)} )}
</> </>
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
@@ -16,7 +15,6 @@ interface ExpenseDetailProps {
} }
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => { const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request'); const [activeTab, setActiveTab] = useState<string>('request');
const expenseDetailTabs = useMemo(() => { const expenseDetailTabs = useMemo(() => {
@@ -48,8 +46,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'> <section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href='/expense'
variant='link' variant='link'
onClick={router.back}
className='w-fit p-0 text-primary' className='w-fit p-0 text-primary'
> >
<Icon icon='uil:arrow-left' width={24} height={24} /> <Icon icon='uil:arrow-left' width={24} height={24} />
@@ -1,8 +1,5 @@
'use client';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -19,7 +16,6 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant'; import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps { interface ExpenseRealizationContentProps {
@@ -29,8 +25,6 @@ interface ExpenseRealizationContentProps {
const ExpenseRealizationContent = ({ const ExpenseRealizationContent = ({
initialValues, initialValues,
}: ExpenseRealizationContentProps) => { }: ExpenseRealizationContentProps) => {
const searchParams = useSearchParams();
const formik = useFormik<UploadRequestDocumentsFormValues>({ const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: { initialValues: {
documents: [], documents: [],
@@ -80,11 +74,7 @@ const ExpenseRealizationContent = ({
<Button <Button
type='button' type='button'
color='warning' color='warning'
href={buildExpenseActionHref( href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
'/expense/realization/edit/',
initialValues?.id as number,
searchParams
)}
className='px-4 grow sm:grow-0' className='px-4 grow sm:grow-0'
> >
<Icon icon='mdi:pencil-outline' width={24} height={24} /> <Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useSWRConfig } from 'swr';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,7 +19,6 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton'; import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
@@ -28,15 +26,11 @@ import {
UploadRequestDocumentsFormSchema, UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues, UploadRequestDocumentsFormValues,
} from '@/components/pages/expense/form/ExpenseRequestForm.schema'; } from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
import { ExpenseApi } from '@/services/api/expense'; import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line'; import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import {
buildExpenseActionHref,
getExpenseListReturnTo,
} from '@/lib/expense-list-navigation';
interface ExpenseRequestContentProps { interface ExpenseRequestContentProps {
initialValues?: Expense; initialValues?: Expense;
@@ -46,13 +40,6 @@ const ExpenseRequestContent = ({
initialValues, initialValues,
}: ExpenseRequestContentProps) => { }: ExpenseRequestContentProps) => {
const router = useRouter(); 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 } = const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({ useApprovalSteps({
@@ -102,24 +89,17 @@ const ExpenseRequestContent = ({
!isLatestApprovalRejected && !isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4; initialValues?.latest_approval.step_number === 4;
const isExpensePaidOff = initialValues?.is_paid;
const showPaidOffButton =
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
// Modal hooks // Modal hooks
const deleteModal = useModal(); const deleteModal = useModal();
const completeModal = useModal(); const completeModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
const paidOffModal = useModal();
// Modal loading state // Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false); const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false); const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const formik = useFormik<UploadRequestDocumentsFormValues>({ const formik = useFormik<UploadRequestDocumentsFormValues>({
@@ -160,31 +140,7 @@ const ExpenseRequestContent = ({
rejectModal.openModal(); rejectModal.openModal();
}; };
const paidOffClickHandler = () => {
paidOffModal.openModal();
};
// Modal confirm click handler // 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 () => { const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -192,7 +148,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(deleteResponse)) { if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!'); toast.success('Berhasil menghapus data biaya operasional!');
router.push(returnTo); router.push('/expense');
} else { } else {
toast.error('Gagal menghapus data biaya operasional!'); toast.error('Gagal menghapus data biaya operasional!');
} }
@@ -208,7 +164,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(completeRes)) { if (isResponseSuccess(completeRes)) {
toast.success(completeRes.message); toast.success(completeRes.message);
router.push(returnTo); router.push('/expense');
} else { } else {
toast.error(completeRes?.message as string); toast.error(completeRes?.message as string);
} }
@@ -248,7 +204,7 @@ const ExpenseRequestContent = ({
toast.success(approveResponse?.message); toast.success(approveResponse?.message);
setApprovalNotes(''); setApprovalNotes('');
router.push(returnTo); router.push('/expense');
} else { } else {
approveModal.closeModal(); approveModal.closeModal();
@@ -283,7 +239,7 @@ const ExpenseRequestContent = ({
toast.success(rejectResponse.message); toast.success(rejectResponse.message);
setApprovalNotes(''); setApprovalNotes('');
router.push(returnTo); router.push('/expense');
} else { } else {
rejectModal.closeModal(); rejectModal.closeModal();
@@ -323,6 +279,8 @@ const ExpenseRequestContent = ({
)} )}
<div className='w-full mt-4 flex flex-col gap-4'> <div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'> <div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && ( {isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'> <RequirePermission permissions='lti.expense.approve.head_area'>
@@ -409,11 +367,7 @@ const ExpenseRequestContent = ({
<Button <Button
variant='outline' variant='outline'
color='info' color='info'
href={buildExpenseActionHref( href={`/expense/realization/?expenseId=${initialValues?.id}`}
'/expense/realization/',
initialValues?.id as number,
searchParams
)}
className='w-full sm:w-fit' className='w-full sm:w-fit'
> >
<Icon <Icon
@@ -426,35 +380,13 @@ const ExpenseRequestContent = ({
</RequirePermission> </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'> <div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && ( {showEditButton && (
<RequirePermission permissions='lti.expense.update'> <RequirePermission permissions='lti.expense.update'>
<Button <Button
type='button' type='button'
color='warning' color='warning'
href={buildExpenseActionHref( href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
'/expense/detail/edit/',
initialValues?.id as number,
searchParams
)}
className='px-4 grow sm:grow-0' className='px-4 grow sm:grow-0'
> >
<Icon icon='mdi:pencil-outline' width={24} height={24} /> <Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -589,19 +521,6 @@ const ExpenseRequestContent = ({
/> />
</td> </td>
</tr> </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> <tr>
<th>Dokumen Pengajuan</th> <th>Dokumen Pengajuan</th>
<th>:</th> <th>:</th>
@@ -617,15 +536,21 @@ const ExpenseRequestContent = ({
<ul className='list-disc'> <ul className='list-disc'>
{initialValues?.documents.map( {initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => { (requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return ( return (
<li key={requestDocumentIdx}> <li key={requestDocumentIdx}>
<Link <Link
href={requestDocument.path} href={documentUrl}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-blue-500 underline' className='text-blue-500 underline'
> >
{requestDocument.name}{' '} {requestDocument.path}{' '}
<Icon <Icon
icon='cuida:open-in-new-tab-outline' icon='cuida:open-in-new-tab-outline'
width={12} width={12}
@@ -821,21 +746,6 @@ const ExpenseRequestContent = ({
onClick: confirmationModalRejectClickHandler, 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,60 +3,26 @@ import * as yup from 'yup';
export type ExpensesFilterType = { export type ExpensesFilterType = {
transaction_date: string | null; transaction_date: string | null;
realization_date: string | null; realization_date: string | null;
location: { value: number; label: string } | null; location_id: string | null;
vendor: { value: number; label: string } | null; vendor_id: 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({ export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(), transaction_date: yup.string().nullable(),
realization_date: yup.string().nullable(), realization_date: yup
location: yup .string()
.object({ .nullable()
value: yup.number().required(), .test(
label: yup.string().required(), 'is-greater-or-equal-transaction',
}) 'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
.nullable(), function (value) {
vendor: yup const { transaction_date } = this.parent;
.object({ if (!transaction_date || !value) return true;
value: yup.number().required(), return new Date(value) >= new Date(transaction_date);
label: yup.string().required(), }
}) ),
.nullable(), location_id: yup.string().nullable(),
category: yup vendor_id: yup.string().nullable(),
.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>; export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import { RefObject } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -11,11 +11,8 @@ import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
import { import {
ExpensesFilterSchema, ExpensesFilterSchema,
ExpensesFilterValues, ExpensesFilterValues,
@@ -34,143 +31,64 @@ const ExpensesFilterModal = ({
onSubmit, onSubmit,
onReset, onReset,
}: ExpensesFilterModalProps) => { }: ExpensesFilterModalProps) => {
const [selectedLocationId, setSelectedLocationId] = useState<string>(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); 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 { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { const {
setInputValue: setVendorInputValue, setInputValue: setVendorInputValue,
options: vendorOptions, options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions, isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendors,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = 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>({ const formik = useFormik<ExpensesFilterValues>({
enableReinitialize: true,
initialValues: initialValues || { initialValues: initialValues || {
transaction_date: null, transaction_date: null,
realization_date: null, realization_date: null,
location: null, location_id: null,
vendor: null, vendor_id: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
}, },
validationSchema: ExpensesFilterSchema, validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
onSubmit?.(values); onSubmit?.(values);
closeModalHandler(); closeModalHandler();
}, },
onReset: () => {
onReset?.();
closeModalHandler();
},
}); });
useEffect(() => { const locationValue = formik.values.location_id
setSelectedLocationId( ? locationOptions.find(
initialValues?.location?.value ? String(initialValues.location.value) : '' (opt) => String(opt.value) === formik.values.location_id
); ) || null
}, [initialValues?.location]); : null;
const { resetForm } = formik; const vendorValue = formik.values.vendor_id
? vendorOptions.find(
const formikResetHandler = useCallback(() => { (opt) => String(opt.value) === formik.values.vendor_id
resetForm({ ) || null
values: { : null;
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 locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const value = val as OptionType | null; const locationId =
formik.setFieldValue('location', value); val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('project_flock', null); formik.setFieldValue('location_id', locationId);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(value?.value ? String(value.value) : '');
}; };
const vendorChangeHandler = (val: OptionType | OptionType[] | null) => { const vendorChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('vendor', val as OptionType | null); const vendorId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('vendor_id', vendorId);
}; };
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 ( return (
<Modal <Modal
ref={ref} ref={ref}
@@ -180,7 +98,7 @@ const ExpensesFilterModal = ({
> >
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formikResetHandler} onReset={formik.handleReset}
className='w-full flex flex-col' className='w-full flex flex-col'
> >
{/* Modal Header */} {/* Modal Header */}
@@ -203,41 +121,49 @@ const ExpensesFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<DateInput <div className='flex flex-col'>
name='transaction_date' <span className='py-2 text-xs font-semibold'>Tanggal</span>
label='Tanggal Transaksi' <div className='flex flex-row items-center gap-1.5'>
placeholder='Tanggal Transaksi' <DateInput
value={formik.values.transaction_date || ''} name='transaction_date'
onChange={formik.handleChange} placeholder='Tanggal Transaksi'
onBlur={formik.handleBlur} value={formik.values.transaction_date || ''}
isError={ onChange={formik.handleChange}
formik.touched.transaction_date && onBlur={formik.handleBlur}
!!formik.errors.transaction_date isError={
} formik.touched.transaction_date &&
/> !!formik.errors.transaction_date
}
<DateInput />
name='realization_date' <hr className='w-full max-w-3 h-px border-base-content/10' />
label='Tanggal Realisasi' <DateInput
placeholder='Tanggal Realisasi' name='realization_date'
value={formik.values.realization_date || ''} placeholder='Tanggal Realisasi'
onChange={formik.handleChange} value={formik.values.realization_date || ''}
onBlur={formik.handleBlur} onChange={formik.handleChange}
isError={ onBlur={formik.handleBlur}
formik.touched.realization_date && isError={
!!formik.errors.realization_date 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>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={formik.values.location} value={locationValue}
onChange={locationChangeHandler} onChange={locationChangeHandler}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isClearable isClearable
isSearchable={true} isSearchable={true}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -247,87 +173,14 @@ const ExpensesFilterModal = ({
label='Vendor' label='Vendor'
placeholder='Pilih Vendor' placeholder='Pilih Vendor'
options={vendorOptions} options={vendorOptions}
value={formik.values.vendor} value={vendorValue}
onChange={vendorChangeHandler} onChange={vendorChangeHandler}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions} isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreVendors}
isClearable isClearable
isSearchable={true} isSearchable={true}
className={{ wrapper: 'w-full' }} 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> </div>
{/* Modal Footer */} {/* Modal Footer */}
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -35,7 +35,6 @@ import { isResponseError } from '@/lib/api-helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data'; import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant'; import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -49,8 +48,6 @@ const ExpenseRealizationForm = ({
initialValues, initialValues,
}: ExpenseRealizationFormProps) => { }: ExpenseRealizationFormProps) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState(''); const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
@@ -67,9 +64,9 @@ const ExpenseRealizationForm = ({
} }
toast.success(createExpenseRes?.message as string); toast.success(createExpenseRes?.message as string);
router.push(returnTo); router.push('/expense');
}, },
[initialValues?.id, returnTo, router] [router, initialValues?.id]
); );
const updateExpenseHandler = useCallback( const updateExpenseHandler = useCallback(
@@ -86,9 +83,9 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string); toast.success(updateExpenseRes?.message as string);
router.refresh(); router.refresh();
router.push(returnTo); router.push('/expense');
}, },
[returnTo, router] [router]
); );
const formik = useFormik<ExpenseRealizationFormValues>({ const formik = useFormik<ExpenseRealizationFormValues>({
@@ -210,7 +207,7 @@ const ExpenseRealizationForm = ({
// add new realizations for each kandang // add new realizations for each kandang
kandangs.forEach((kandangItem) => { kandangs.forEach((kandangItem) => {
if (isNaN(Number(kandangItem.id))) return; if (!kandangItem.id) return;
const existingRealization = formik.values.realizations?.find( const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id (realizationItem) => realizationItem.kandang_id === kandangItem.id
@@ -261,7 +258,7 @@ const ExpenseRealizationForm = ({
<section className='w-full'> <section className='w-full'>
<header className='flex flex-col gap-4'> <header className='flex flex-col gap-4'>
<Button <Button
href={returnTo} href='/expense'
variant='link' variant='link'
className='w-fit p-0 text-primary' className='w-fit p-0 text-primary'
> >
@@ -35,7 +35,6 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
setInputValue: setNonstockInputValue, setInputValue: setNonstockInputValue,
options: nonstockOptions, options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions, isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>( } = useSelect<Nonstock>(
NonstockApi.basePath, NonstockApi.basePath,
'id', 'id',
@@ -165,7 +164,6 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
options={nonstockOptions} options={nonstockOptions}
isLoading={isLoadingNonstockOptions} isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue} onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
className={{ wrapper: 'min-w-48' }} className={{ wrapper: 'min-w-48' }}
isDisabled isDisabled
/> />
@@ -178,14 +178,14 @@ const ExpenseRequestForm = ({
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocationOptions, isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations, loadMore: loadMoreLocationOptions,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name'); } = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const { const {
setInputValue: setVendorInputValue, setInputValue: setVendorInputValue,
options: supplierOptions, options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions, isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreSuppliers, loadMore: loadMoreVendorOptions,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => { const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -410,13 +410,13 @@ const ExpenseRequestForm = ({
options={locationOptions} options={locationOptions}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={ isError={
formik.touched.location_id && Boolean(formik.errors.location_id) formik.touched.location_id && Boolean(formik.errors.location_id)
} }
errorMessage={formik.errors.location_id as string} errorMessage={formik.errors.location_id as string}
isClearable isClearable
className={{ wrapper: 'col-span-12 sm:col-span-4' }} className={{ wrapper: 'col-span-12 sm:col-span-4' }}
onMenuScrollToBottom={loadMoreLocationOptions}
/> />
<DateInput <DateInput
@@ -455,12 +455,12 @@ const ExpenseRequestForm = ({
options={supplierOptions} options={supplierOptions}
onInputChange={setVendorInputValue} onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions} isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={ isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id) formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
} }
errorMessage={formik.errors.supplier_id as string} errorMessage={formik.errors.supplier_id as string}
className={{ wrapper: 'col-span-12' }} className={{ wrapper: 'col-span-12' }}
onMenuScrollToBottom={loadMoreVendorOptions}
/> />
<RequirePermission permissions='lti.expense.document'> <RequirePermission permissions='lti.expense.document'>
+236 -559
View File
@@ -1,212 +1,154 @@
'use client'; 'use client';
import { import React from 'react';
Document, import { Document, Page, StyleSheet, View, Text } from '@react-pdf/renderer';
Image,
Link,
Page,
StyleSheet,
Text,
View,
} from '@react-pdf/renderer';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { PdfTypography } from '@/components/helper/pdf/typography/PdfTypography';
import { PdfParamBadge } from '@/components/helper/pdf/badge/PdfParamBadge';
import { PdfPageNumber } from '@/components/helper/pdf/layout/PdfPageNumber';
import { PdfTable, PdfColumn } from '@/components/helper/pdf/table';
interface ExpensePDFProps { interface ExpensePDFProps {
expense?: Expense; expense?: Expense;
} }
const ExpensePDFStyle = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
paddingTop: 24, fontSize: 10,
paddingBottom: 64, fontFamily: 'Helvetica',
paddingHorizontal: 32, padding: 20,
backgroundColor: '#FFFFFF',
}, },
titleSection: {
companyInfoHeader: {
width: '100%',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
companyLogo: {
width: 64,
height: 'auto',
},
companyInfoHeaderDate: {
paddingTop: 8,
fontSize: 12,
},
companyName: {
fontSize: 12,
fontWeight: 'bold',
marginBottom: 4,
},
companyAddress: {
fontSize: 8,
maxWidth: 400,
marginBottom: 10, marginBottom: 10,
}, },
parameterContainer: {
title: {
marginTop: 16,
fontSize: 16,
lineHeight: '150%',
textAlign: 'center',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
},
footer: {
width: '100%',
display: 'flex',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', flexWrap: 'wrap',
alignItems: 'center', marginBottom: 8,
paddingHorizontal: 32, },
infoTableSection: {
position: 'absolute', marginBottom: 12,
},
infoTableTitle: {
fontSize: 10, fontSize: 10,
bottom: 30,
left: 0,
right: 0,
textAlign: 'center',
color: 'grey',
},
// wrapper
generalInfoTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
generalInfoTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
// columns
generalInfoTableColLabel: {
width: '30%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableColSeparator: {
width: '3%',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 6,
},
generalInfoTableColValue: {
width: '67%',
paddingVertical: 6,
paddingHorizontal: 8,
},
generalInfoTableLabelText: {
fontWeight: 'bold', fontWeight: 'bold',
marginBottom: 6,
color: '#333',
}, },
generalInfoTableValueText: {}, tableSection: {
marginBottom: 12,
// expense detail table
expenseDetailContainer: {
width: '100%',
marginTop: 12,
}, },
expenseDetailTitle: { tableTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseContainer: {
width: '100%',
marginTop: 8,
},
kandangExpenseTitle: {
fontSize: 14,
lineHeight: '150%',
fontFamily: 'Times-Roman',
fontWeight: 'bold',
textAlign: 'center',
},
kandangExpenseTable: {
width: '100%',
marginTop: 8,
borderWidth: 1,
borderColor: '#000000',
borderBottomWidth: 0,
fontSize: 12,
},
kandangExpenseTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#000000',
},
kandangExpenseTableColLabel: {
width: '20%',
paddingVertical: 6,
paddingHorizontal: 8,
},
kandangExpenseTableColLabelBorderRight: {
borderRight: '1px solid #000000',
},
kandangExpenseTableColNonstock: {
width: '20%',
},
kandangExpenseTableColNote: {
width: '40%',
},
kandangExpenseHeaderLabelText: {
fontWeight: 'bold',
},
kandangExpenseLabelText: {
fontSize: 10, fontSize: 10,
fontWeight: 'bold',
marginBottom: 6,
color: '#333',
}, },
kandangExpenseTableFooterColTotalExpenseCaption: { emptyText: {
width: '40%', fontSize: 8,
paddingVertical: 6, color: '#666',
paddingHorizontal: 8, fontStyle: 'italic',
textAlign: 'right',
},
kandangExpenseTableFooterColTotalExpenseValue: {
width: '60%',
paddingVertical: 6,
paddingHorizontal: 8,
},
// utils
doubleDivider: {
width: '100%',
height: 6,
borderTop: '2px solid black',
borderBottom: '2px solid black',
}, },
}); });
const ExpensePDF = ({ expense }: ExpensePDFProps) => { type ExpenseKandang = Expense['kandangs'][number];
type PengajuanItem = NonNullable<ExpenseKandang['pengajuans']>[number];
type RealisasiItem = NonNullable<ExpenseKandang['realisasi']>[number];
const valueText = (v: unknown) => {
if (v === null || v === undefined) return '-';
if (typeof v === 'number') return formatNumber(v);
return String(v);
};
const getPengajuanColumns = (): PdfColumn<PengajuanItem>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ index }) => index + 1,
},
{
key: 'nonstock',
header: 'Nonstock',
flex: 1.5,
cell: ({ row }) => row.nonstock.name,
},
{
key: 'qty',
header: 'Kuantitas',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.qty),
},
{
key: 'price',
header: 'Harga Satuan',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.price),
},
{
key: 'notes',
header: 'Catatan',
flex: 1.5,
cell: ({ row }) => row.notes || '-',
},
];
const getRealisasiColumns = (): PdfColumn<RealisasiItem>[] => [
{
key: 'no',
header: 'No',
flex: 0.5,
align: 'center',
cell: ({ index }) => index + 1,
},
{
key: 'nonstock',
header: 'Nonstock',
flex: 1.5,
cell: ({ row }) => row.nonstock.name,
},
{
key: 'qty',
header: 'Kuantitas',
flex: 1,
align: 'right',
cell: ({ row }) => valueText(row.qty),
},
{
key: 'price',
header: 'Harga Satuan',
flex: 1.2,
align: 'right',
cell: ({ row }) => formatCurrency(row.price),
},
{
key: 'notes',
header: 'Catatan',
flex: 1.5,
cell: ({ row }) => row.notes || '-',
},
];
const getInfoTableRows = (expense?: Expense) => {
const isLatestApprovalRejected = const isLatestApprovalRejected =
expense?.latest_approval?.action === 'REJECTED'; expense?.latest_approval?.action === 'REJECTED';
const isExpenseRealized = const isExpenseRealized =
expense?.latest_approval?.step_number && expense?.latest_approval?.step_number &&
expense?.latest_approval.step_number >= 5; expense?.latest_approval.step_number >= 5;
const realizationStatus = isExpenseRealized const realizationStatus = isExpenseRealized
? 'Sudah Realisasi' ? 'Sudah Realisasi'
: 'Belum Realisasi'; : 'Belum Realisasi';
const rows = [ return [
{ label: 'Nomor PO', value: expense?.po_number }, { label: 'Nomor PO', value: expense?.po_number || '-' },
{ label: 'Nomor Referensi', value: expense?.reference_number }, { label: 'Nomor Referensi', value: expense?.reference_number || '-' },
{ {
label: 'Kategori', label: 'Kategori',
value: value:
@@ -214,9 +156,9 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
? 'Biaya Operasional' ? 'Biaya Operasional'
: expense?.category === 'NON-BOP' : expense?.category === 'NON-BOP'
? 'Non Biaya Operasional' ? 'Non Biaya Operasional'
: '', : '-',
}, },
{ label: 'Lokasi', value: expense?.location.name }, { label: 'Lokasi', value: expense?.location?.name || '-' },
{ {
label: 'Kandang', label: 'Kandang',
value: value:
@@ -227,7 +169,7 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
.join(', ') .join(', ')
: '-', : '-',
}, },
{ label: 'Vendor', value: expense?.supplier.name }, { label: 'Vendor', value: expense?.supplier?.name || '-' },
{ {
label: 'Tanggal Transaksi', label: 'Tanggal Transaksi',
value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'), value: formatDate(expense?.transaction_date, 'DD MMMM YYYY'),
@@ -238,12 +180,12 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
? formatDate(expense?.realization_date, 'DD MMMM YYYY') ? formatDate(expense?.realization_date, 'DD MMMM YYYY')
: '-', : '-',
}, },
{ label: 'Nama Pengaju', value: expense?.created_user.name }, { label: 'Nama Pengaju', value: expense?.created_user?.name || '-' },
{ {
label: 'Nominal Biaya', label: 'Nominal Biaya',
value: formatCurrency( value: formatCurrency(
expense?.latest_approval.step_number === 5 || expense?.latest_approval?.step_number === 5 ||
expense?.latest_approval.step_number === 6 expense?.latest_approval?.step_number === 6
? (expense?.total_realisasi ?? 0) ? (expense?.total_realisasi ?? 0)
: (expense?.total_pengajuan ?? 0) : (expense?.total_pengajuan ?? 0)
), ),
@@ -263,401 +205,136 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
label: 'Status Biaya', label: 'Status Biaya',
value: isLatestApprovalRejected value: isLatestApprovalRejected
? 'Ditolak' ? 'Ditolak'
: expense?.latest_approval?.step_name, : expense?.latest_approval?.step_name || '-',
}, },
]; ];
};
interface InfoRow {
label: string;
value: string;
}
const getInfoTableColumns = (): PdfColumn<InfoRow>[] => [
{
key: 'label',
header: 'Field',
flex: 1,
cell: ({ row }) => row.label,
},
{
key: 'value',
header: 'Value',
flex: 2,
cell: ({ row }) => row.value,
},
];
const ExpensePDF = ({ expense }: ExpensePDFProps) => {
const kandangs = expense?.kandangs || [];
const infoRows = getInfoTableRows(expense);
return ( return (
<Document> <Document>
<Page style={ExpensePDFStyle.page}> <Page style={styles.page} size='A4'>
<View> {/* Title Section */}
<View style={ExpensePDFStyle.companyInfoHeader}> <View style={styles.titleSection}>
<Image <PdfTypography size='h1' variant='primary'>
style={ExpensePDFStyle.companyLogo} Laporan{' '}
src='/assets/img/lti-logo.png' {expense?.category === 'BOP'
/> ? 'Biaya Operasional'
: 'Non-Biaya Operasional'}
<Text style={ExpensePDFStyle.companyInfoHeaderDate}> </PdfTypography>
{formatDate(Date.now(), 'DD MMMM YYYY')} <PdfTypography size='h2'>{expense?.po_number || '-'}</PdfTypography>
</Text> <View style={styles.parameterContainer}>
</View> <PdfParamBadge>
Tanggal: {formatDate(Date.now(), 'DD MMMM YYYY')}
<View> </PdfParamBadge>
<Text style={ExpensePDFStyle.companyName}> <PdfParamBadge>
PT LUMBUNG TELUR INDONESIA Dicetak: {formatDate(new Date(), 'DD MMM YYYY HH:mm')}
</Text> </PdfParamBadge>
<Text style={ExpensePDFStyle.companyAddress}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
</Text>
<View style={ExpensePDFStyle.doubleDivider} />
</View> </View>
</View> </View>
<Text style={ExpensePDFStyle.title}> {/* Info Table Section */}
Laporan{' '} <View style={styles.infoTableSection}>
{expense?.category === 'BOP' <Text style={styles.infoTableTitle}>Informasi Biaya</Text>
? 'Biaya Operasional' <PdfTable columns={getInfoTableColumns()} data={infoRows} />
: 'Non-Biaya Operasional'}{' '}
{expense?.po_number}
</Text>
{/* General info table */}
<View style={ExpensePDFStyle.generalInfoTable}>
{rows.map((row) => (
<View style={ExpensePDFStyle.generalInfoTableRow} key={row.label}>
<View style={ExpensePDFStyle.generalInfoTableColLabel}>
<Text style={ExpensePDFStyle.generalInfoTableLabelText}>
{row.label}
</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColSeparator}>
<Text>:</Text>
</View>
<View style={ExpensePDFStyle.generalInfoTableColValue}>
<Text style={ExpensePDFStyle.generalInfoTableValueText}>
{row.value}
</Text>
</View>
</View>
))}
</View> </View>
{/* Detail expense request */} {/* Rincian Pengajuan Section */}
<View <View style={styles.tableSection}>
minPresenceAhead={80} <Text style={styles.tableTitle}>1. Rincian Pengajuan Biaya</Text>
style={ExpensePDFStyle.expenseDetailContainer} {kandangs.length === 0 ? (
> <Text style={styles.emptyText}>Tidak ada data pengajuan.</Text>
<Text style={ExpensePDFStyle.expenseDetailTitle}> ) : (
Rincian Pengajuan Biaya Operasional kandangs.map((kandang, idx) => {
</Text> const pengajuans = kandang.pengajuans || [];
const kandangName =
kandang.kandang_id && kandang.name
? kandang.name
: expense?.location?.name || 'Umum';
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => { return (
let expenseRequestTotal = 0; <View key={idx} style={{ marginBottom: 12 }}>
<PdfTypography size='h3' style={{ paddingLeft: 12 }}>
kandangExpense.pengajuans?.forEach( {idx + 1}) {kandangName}
(item) => (expenseRequestTotal += item.qty * item.price) </PdfTypography>
); {pengajuans.length > 0 ? (
<PdfTable
return ( columns={getPengajuanColumns()}
<View data={pengajuans}
key={kandangExpenseIdx} showFooter={true}
style={ExpensePDFStyle.kandangExpenseContainer} footerLabel='Total'
> />
<Text style={ExpensePDFStyle.kandangExpenseTitle}> ) : (
{kandangExpense.kandang_id && kandangExpense.name <Text style={styles.emptyText}>
? `Biaya ${kandangExpense.name}` Tidak ada item pengajuan untuk kandang ini.
: `Biaya ${expense?.location.name || 'Umum'}`} </Text>
</Text> )}
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Harga Satuan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.pengajuans?.map((pengajuan, pengajuanIdx) => (
<View
key={pengajuanIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(pengajuan.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(pengajuan.price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{pengajuan.notes}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRequestTotal)}
</Text>
</View>
</View>
</View> </View>
</View> );
); })
})} )}
</View> </View>
{/* Detail expense realization */} {/* Rincian Realisasi Section */}
<View <View style={styles.tableSection}>
minPresenceAhead={80} <Text style={styles.tableTitle}>2. Rincian Realisasi Biaya</Text>
style={ExpensePDFStyle.expenseDetailContainer} {kandangs.length === 0 ? (
> <Text style={styles.emptyText}>Tidak ada data realisasi.</Text>
<Text style={ExpensePDFStyle.expenseDetailTitle}> ) : (
Rincian Realisasi Biaya Operasional kandangs.map((kandang, idx) => {
</Text> const realisasi = kandang.realisasi || [];
const kandangName =
kandang.kandang_id && kandang.name
? kandang.name
: expense?.location?.name || 'Umum';
{expense?.kandangs.map((kandangExpense, kandangExpenseIdx) => { return (
let expenseRealizationTotal = 0; <View key={idx} style={{ marginBottom: 12 }}>
<PdfTypography size='h3' style={{ paddingLeft: 12 }}>
kandangExpense.realisasi?.forEach( {idx + 1}) {kandangName}
(item) => (expenseRealizationTotal += item.qty * item.price) </PdfTypography>
); {realisasi.length > 0 ? (
<PdfTable
return ( columns={getRealisasiColumns()}
<View data={realisasi}
key={kandangExpenseIdx} showFooter={true}
style={ExpensePDFStyle.kandangExpenseContainer} footerLabel='Total'
> />
<Text style={ExpensePDFStyle.kandangExpenseTitle}> ) : (
{kandangExpense.kandang_id && kandangExpense.name <Text style={styles.emptyText}>
? `Biaya ${kandangExpense.name}` Tidak ada item realisasi untuk kandang ini.
: `Biaya ${expense?.location.name || 'Umum'}`} </Text>
</Text> )}
<View style={ExpensePDFStyle.kandangExpenseTable}>
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Nonstock
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Kuantitas
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Harga Satuan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Catatan
</Text>
</View>
</View>
{kandangExpense.realisasi?.map((realisasi, realisasiIdx) => (
<View
key={realisasiIdx}
style={ExpensePDFStyle.kandangExpenseTableRow}
>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
ExpensePDFStyle.kandangExpenseTableColNonstock,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.nonstock.name}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatNumber(realisasi.qty)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{formatCurrency(realisasi.price)}
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableColLabel,
ExpensePDFStyle.kandangExpenseTableColNote,
]}
>
<Text style={ExpensePDFStyle.kandangExpenseLabelText}>
{realisasi.notes}
</Text>
</View>
</View>
))}
<View style={[ExpensePDFStyle.kandangExpenseTableRow]}>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseCaption,
ExpensePDFStyle.kandangExpenseTableColLabelBorderRight,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
Total Biaya Keseluruhan
</Text>
</View>
<View
style={[
ExpensePDFStyle.kandangExpenseTableFooterColTotalExpenseValue,
]}
>
<Text
style={ExpensePDFStyle.kandangExpenseHeaderLabelText}
>
{formatCurrency(expenseRealizationTotal)}
</Text>
</View>
</View>
</View> </View>
</View> );
); })
})} )}
</View> </View>
<View style={ExpensePDFStyle.footer} fixed> <PdfPageNumber />
<Link
src={`${process.env.NEXT_PUBLIC_LTI_URL}expense/detail?expenseId=${expense?.id}`}
>
{expense?.po_number}
</Link>
<Text
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
</View>
</Page> </Page>
</Document> </Document>
); );
+151 -197
View File
@@ -1,12 +1,13 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, {
import { useCallback,
CellContext, useEffect,
ColumnDef, useMemo,
SortingState, useRef,
Updater, useState,
} from '@tanstack/react-table'; } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -38,7 +39,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter'; import { useUiStore } from '@/stores/ui/ui.store';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
@@ -175,6 +176,9 @@ const RowOptionsMenu = ({
}; };
const FinanceTable = () => { const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -183,18 +187,14 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
transactionTypes: '', transactionTypes: '',
bankIds: '', bankIds: '',
customerIds: '', customerIds: '',
supplierIds: '', supplierIds: '',
sort_by: '', sortBy: '',
orderBy: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
bankNames: '',
customerNames: '',
supplierNames: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -203,14 +203,10 @@ const FinanceTable = () => {
bankIds: 'bank_ids', bankIds: 'bank_ids',
customerIds: 'customer_ids', customerIds: 'customer_ids',
supplierIds: 'supplier_ids', supplierIds: 'supplier_ids',
sort_by: 'sort_by', sortBy: 'sort_date',
orderBy: 'sort_order',
startDate: 'start_date', startDate: 'start_date',
endDate: 'end_date', endDate: 'end_date',
}, },
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
persist: true,
storeName: 'finance-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -239,7 +235,7 @@ const FinanceTable = () => {
// ===== Formik for Filter ===== // ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({ const filterFormik = useFormik<FinanceTableFilterValues>({
initialValues: { initialValues: {
search: tableFilterState.search || '', search: searchValue,
transaction_types: '', transaction_types: '',
bank_ids: '', bank_ids: '',
customer_ids: '', customer_ids: '',
@@ -249,48 +245,29 @@ const FinanceTable = () => {
end_date: '', end_date: '',
}, },
validationSchema: FinanceTableFilterSchema, validationSchema: FinanceTableFilterSchema,
onSubmit: (values, { setSubmitting }) => { enableReinitialize: true,
updateFilter('search', values.search, true); onSubmit: (values) => {
updateFilter('transactionTypes', values.transaction_types, true); updateFilter('search', values.search);
updateFilter('bankIds', values.bank_ids, true); setSearchValue(values.search);
updateFilter('customerIds', values.customer_ids, true); updateFilter('transactionTypes', values.transaction_types);
updateFilter('supplierIds', values.supplier_ids, true); updateFilter('bankIds', values.bank_ids);
updateFilter('sort_by', values.sort_by, true); updateFilter('customerIds', values.customer_ids);
updateFilter('startDate', values.start_date, true); updateFilter('supplierIds', values.supplier_ids);
updateFilter('endDate', values.end_date, true); updateFilter('sortBy', values.sort_by);
// Save display names for restoration on modal reopen updateFilter('startDate', values.start_date);
const toNames = (val: OptionType | OptionType[] | null) => updateFilter('endDate', values.end_date);
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(); filterModal.closeModal();
setSubmitting(false);
}, },
onReset: () => { onReset: () => {
setSelectedTransactionType(null); updateFilter('search', '');
setSelectedBank(null); resetSearchValue();
setSelectedCustomerId(null); updateFilter('transactionTypes', '');
setSelectedSupplierId(null); updateFilter('bankIds', '');
setSelectedSortBy(null); updateFilter('customerIds', '');
updateFilter('search', '', true); updateFilter('supplierIds', '');
updateFilter('transactionTypes', '', true); updateFilter('sortBy', '');
updateFilter('bankIds', '', true); updateFilter('startDate', '');
updateFilter('customerIds', '', true); updateFilter('endDate', '');
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();
}, },
}); });
@@ -343,10 +320,40 @@ const FinanceTable = () => {
}); });
}, [bankOptions, bankRawData]); }, [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 ===== // ===== Handler =====
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const searchChangeHandler = useCallback(
updateFilter('search', e.target.value, true); (e: React.ChangeEvent<HTMLInputElement>) => {
}; updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = ( const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
@@ -402,26 +409,6 @@ const FinanceTable = () => {
); );
}; };
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.orderBy === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('orderBy', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('orderBy', '', true);
}
};
const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const startDateChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
const endDate = filterFormik.values.end_date; const endDate = filterFormik.values.end_date;
@@ -482,74 +469,28 @@ const FinanceTable = () => {
}; };
const handleFilterModalOpen = () => { 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(); 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 () => { const confirmationModalDeleteClickHandler = async () => {
@@ -568,12 +509,10 @@ const FinanceTable = () => {
{ {
header: 'ID', header: 'ID',
accessorKey: 'payment_code', accessorKey: 'payment_code',
enableSorting: true,
}, },
{ {
header: 'References Number', header: 'References Number',
accessorKey: 'reference_number', accessorKey: 'reference_number',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.reference_number; const value = props.row.original.reference_number;
return <span>{value ?? '-'}</span>; return <span>{value ?? '-'}</span>;
@@ -582,7 +521,6 @@ const FinanceTable = () => {
{ {
header: 'Jenis Transaksi', header: 'Jenis Transaksi',
accessorKey: 'transaction_type', accessorKey: 'transaction_type',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.transaction_type const value = props.row.original.transaction_type
.split('_') .split('_')
@@ -592,8 +530,7 @@ const FinanceTable = () => {
}, },
{ {
header: 'Pihak', header: 'Pihak',
accessorKey: 'customer_name', accessorFn: (finance: Finance) => finance.party?.name,
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
if (props.row.original.party?.id) { if (props.row.original.party?.id) {
return <span>{props.row.original.party?.name}</span>; return <span>{props.row.original.party?.name}</span>;
@@ -602,23 +539,13 @@ const FinanceTable = () => {
}, },
}, },
{ {
header: 'Tanggal Pembayaran', header: 'Tanggal',
accessorKey: 'payment_date', accessorFn: (finance: Finance) =>
enableSorting: true, formatDate(finance.payment_date, 'DD MMM YYYY'),
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', header: 'Metode Pembayaran',
accessorKey: 'payment_method', accessorKey: 'payment_method',
enableSorting: true,
cell: (props: CellContext<Finance, unknown>) => { cell: (props: CellContext<Finance, unknown>) => {
const value = props.row.original.payment_method.split('_').join(' '); const value = props.row.original.payment_method.split('_').join(' ');
return <span>{formatTitleCase(value)}</span>; return <span>{formatTitleCase(value)}</span>;
@@ -626,26 +553,20 @@ const FinanceTable = () => {
}, },
{ {
header: 'Bank', header: 'Bank',
accessorKey: 'bank', accessorFn: (finance: Finance) =>
enableSorting: true, finance.bank
cell: (props) => ? `${finance.bank?.alias} - ${finance.bank?.account_number} - ${finance.bank?.owner}`
props.row.original.bank
? `${props.row.original.bank?.alias} - ${props.row.original.bank?.account_number} - ${props.row.original.bank?.owner}`
: '-', : '-',
}, },
{ {
header: 'Pengeluaran (Rp)', header: 'Pengeluaran (Rp)',
accessorKey: 'expense_amount', accessorFn: (finance: Finance) =>
enableSorting: true, formatCurrency(Math.abs(finance.expense_amount)),
cell: (props) =>
formatCurrency(Math.abs(props.row.original.expense_amount)),
}, },
{ {
header: 'Pemasukan (Rp)', header: 'Pemasukan (Rp)',
accessorKey: 'income_amount', accessorFn: (finance: Finance) =>
enableSorting: true, formatCurrency(Math.abs(finance.income_amount)),
cell: (props) =>
formatCurrency(Math.abs(props.row.original.income_amount)),
}, },
{ {
header: 'Aksi', header: 'Aksi',
@@ -684,6 +605,27 @@ const FinanceTable = () => {
}; };
}, [dateErrorShown]); }, [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 ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -745,20 +687,25 @@ const FinanceTable = () => {
}} }}
/> />
<ButtonFilter <Button
values={tableFilterState} variant='outline'
excludeFields={[ color='none'
'page',
'pageSize',
'search',
'orderBy',
'bankNames',
'customerNames',
'supplierNames',
]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' 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>
</div> </div>
</div> </div>
@@ -794,9 +741,6 @@ const FinanceTable = () => {
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: cn('p-3 mb-0'), containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
@@ -930,9 +874,19 @@ const FinanceTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset' type='button'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -7,6 +7,7 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table'; import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
@@ -24,10 +25,7 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory'; import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data'; import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { useUiStore } from '@/stores/ui/ui.store';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import toast from 'react-hot-toast';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment'; import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant'; import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
@@ -40,89 +38,27 @@ import {
AdjustmentFilterType, AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter'; } from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import { CellContext } from '@tanstack/react-table';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryAdjustment, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `adjustment#${props.row.original.id}`;
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryAdjustmentTable = () => { const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
productCategorySort: string;
productSort: string;
warehouseSort: string;
stockSort: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
transactionTypeFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productCategorySort: '', productCategorySort: '',
productSort: '', productSort: '',
warehouseSort: '', warehouseSort: '',
stockSort: '', stockSort: '',
productFilter: undefined, productFilter: '',
warehouseFilter: undefined, warehouseFilter: '',
transactionTypeFilter: undefined, transactionTypeFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -135,8 +71,6 @@ const InventoryAdjustmentTable = () => {
warehouseFilter: 'warehouse_id', warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type', transactionTypeFilter: 'transaction_type',
}, },
persist: true,
storeName: 'inventory-adjustment-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -145,27 +79,22 @@ const InventoryAdjustmentTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({ const formik = useFormik<AdjustmentFilterType>({
initialValues: { initialValues: {
product: tableFilterState.productFilter, product_id: null,
warehouse: tableFilterState.warehouseFilter, warehouse_id: null,
transaction_type: tableFilterState.transactionTypeFilter, transaction_type: null,
}, },
validationSchema: AdjustmentFilterSchema, validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product || undefined, true); updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse || undefined, true); updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter( updateFilter('transactionTypeFilter', values.transaction_type || '');
'transactionTypeFilter',
values.transaction_type || undefined,
true
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => { onReset: () => {
updateFilter('productFilter', undefined, true); updateFilter('productFilter', '');
updateFilter('warehouseFilter', undefined, true); updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', undefined, true); updateFilter('transactionTypeFilter', '');
filterModal.closeModal();
}, },
}); });
@@ -204,68 +133,85 @@ const InventoryAdjustmentTable = () => {
}, []); }, []);
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => { const handleFilterProductChange = useCallback(
formik.setFieldValue('product', val); (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 handleFilterWarehouseChange = ( const handleFilterWarehouseChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const warehouse = val as OptionType | null;
formik.setFieldValue('warehouse', val); const warehouseId = warehouse?.value ? String(warehouse.value) : null;
}; formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
const handleFilterTransactionTypeChange = ( const handleFilterTransactionTypeChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const type = val as OptionType | null;
formik.setFieldValue('transaction_type', val); 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 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 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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const { const { data: inventoryAdjustments, isLoading } = useSWR(
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`, `${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher InventoryAdjustmentApi.getAllFetcher
); );
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
InventoryAdjustment | undefined useEffect(() => {
>(undefined); updateFilter('search', searchValue);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); }, [searchValue, updateFilter]);
const singleDeleteModal = useModal();
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo( const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
@@ -368,39 +314,8 @@ const InventoryAdjustmentTable = () => {
header: 'Oleh', header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-', accessorFn: (row) => row.created_user?.name ?? '-',
}, },
{
id: 'actions',
header: 'Aksi',
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedAdjustment(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
], ],
[ []
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedAdjustment,
]
); );
const updateSortingFilter = useCallback( const updateSortingFilter = useCallback(
@@ -486,8 +401,6 @@ const InventoryAdjustmentTable = () => {
'productSort', 'productSort',
'warehouseSort', 'warehouseSort',
'stockSort', 'stockSort',
'productName',
'warehouseName',
]} ]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' className='px-3 py-2.5'
@@ -577,7 +490,7 @@ const InventoryAdjustmentTable = () => {
label='Produk' label='Produk'
placeholder='Pilih Produk' placeholder='Pilih Produk'
options={productOptions} options={productOptions}
value={formik.values.product} value={productIdValue}
onChange={handleFilterProductChange} onChange={handleFilterProductChange}
onInputChange={setProductInputValue} onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions} isLoading={isLoadingProductOptions}
@@ -589,7 +502,7 @@ const InventoryAdjustmentTable = () => {
label='Gudang' label='Gudang'
placeholder='Pilih Gudang' placeholder='Pilih Gudang'
options={warehouseOptions} options={warehouseOptions}
value={formik.values.warehouse} value={warehouseIdValue}
onChange={handleFilterWarehouseChange} onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions} isLoading={isLoadingWarehouseOptions}
@@ -601,7 +514,7 @@ const InventoryAdjustmentTable = () => {
label='Tipe Transaksi' label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi' placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions} options={transactionTypeOptions}
value={formik.values.transaction_type} value={transactionTypeValue}
onChange={handleFilterTransactionTypeChange} onChange={handleFilterTransactionTypeChange}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -611,9 +524,13 @@ const InventoryAdjustmentTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset' type='button'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -627,21 +544,6 @@ const InventoryAdjustmentTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -1,23 +1,13 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const AdjustmentFilterSchema = Yup.object().shape({ export const AdjustmentFilterSchema = object().shape({
product: Yup.object({ product_id: string().nullable(),
value: Yup.string().nullable(), warehouse_id: string().nullable(),
label: Yup.string().nullable(), transaction_type: 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 = { export type AdjustmentFilterType = {
product?: OptionType<string>; product_id: string | null;
warehouse?: OptionType<string>; warehouse_id: string | null;
transaction_type?: OptionType<string>; transaction_type: string | null;
}; };
@@ -15,7 +15,7 @@ import {
InventoryAdjustmentFormSchema, InventoryAdjustmentFormSchema,
InventoryAdjustmentFormValues, InventoryAdjustmentFormValues,
} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema'; } from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema';
import { LocationApi } from '@/services/api/master-data'; import { KandangApi, LocationApi } from '@/services/api/master-data';
import { import {
ProjectFlockApi, ProjectFlockApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
@@ -32,6 +32,8 @@ import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { Location } from '@/types/api/master-data/location'; import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock'; import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
@@ -117,19 +119,40 @@ const InventoryAdjustmentForm = ({
} }
); );
const { rawData: approvedProjectFlockKandangsRawData } =
useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'id',
'search',
{
step_name: 'Disetujui',
limit: '100',
}
);
const approvedProjectFlockKandangs = useMemo(() => {
if (
approvedProjectFlockKandangsRawData &&
'data' in approvedProjectFlockKandangsRawData
) {
return approvedProjectFlockKandangsRawData.data as ProjectFlockKandang[];
}
return [];
}, [approvedProjectFlockKandangsRawData]);
const { const {
options: projectFlockKandangOptions, setInputValue: setKandangInputValue,
loadMore: loadMoreProjectFlockKandangs, options: kandangOptionsFromApi,
setInputValue: setProjectFlockKandangInputValue, isLoadingOptions: isLoadingKandangOptions,
isLoadingOptions: isLoadingProjectFlockKandangOptions, loadMore: loadMoreKandangs,
} = useSelect( } = useSelect<Kandang>(
selectedProjectFlock ? ProjectFlockKandangApi.basePath : '', selectedProjectFlock ? KandangApi.basePath : '',
'kandang.id', 'id',
'kandang.name', 'name',
'search', 'search',
{ {
step_name: 'Disetujui', location_id: selectedProjectFlockLocationId,
project_flock_id: String(selectedProjectFlock?.value),
} }
); );
@@ -162,9 +185,7 @@ const InventoryAdjustmentForm = ({
isLoadingOptions: isLoadingProductOptions, isLoadingOptions: isLoadingProductOptions,
loadMore: loadMoreProducts, loadMore: loadMoreProducts,
rawData: products, rawData: products,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', { } = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search');
include_all: 'true',
});
const { const {
setInputValue: setDepletionProductInputValue, setInputValue: setDepletionProductInputValue,
@@ -199,6 +220,26 @@ const InventoryAdjustmentForm = ({
return (product?.flags as string[]) || []; return (product?.flags as string[]) || [];
}, [selectedProduct, productOptions]); }, [selectedProduct, productOptions]);
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock) {
const approvedKandangIds = approvedProjectFlockKandangs
.filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value)
.map((pfk) => pfk.kandang_id);
options = kandangOptionsFromApi.filter((kandang) =>
approvedKandangIds.includes(kandang.value as number)
);
}
return options;
}, [
selectedProjectFlock,
kandangOptionsFromApi,
approvedProjectFlockKandangs,
]);
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>( const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(
() => ({ () => ({
location: null, location: null,
@@ -650,10 +691,10 @@ const InventoryAdjustmentForm = ({
label='Kandang' label='Kandang'
value={selectedKandang} value={selectedKandang}
onChange={kandangChangeHandler} onChange={kandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue} onInputChange={setKandangInputValue}
options={projectFlockKandangOptions} options={kandangOptions}
onMenuScrollToBottom={loadMoreProjectFlockKandangs} onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingProjectFlockKandangOptions} isLoading={isLoadingKandangOptions}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -13,8 +20,7 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput';
@@ -35,11 +41,9 @@ import {
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
deleteClickHandler,
}: { }: {
popoverPosition: 'bottom' | 'top'; popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>; props: CellContext<Movement, unknown>;
deleteClickHandler: () => void;
}) => { }) => {
const popoverId = `movement#${props.row.original.id}`; const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`; const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
@@ -79,20 +83,6 @@ const RowOptionsMenu = ({
Detail Detail
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.inventory.transfer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div> </div>
</PopoverContent> </PopoverContent>
</div> </div>
@@ -100,21 +90,20 @@ const RowOptionsMenu = ({
}; };
const MovementTable = () => { const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productFilter: undefined, productFilter: '',
warehouseFilter: undefined, warehouseFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -122,8 +111,6 @@ const MovementTable = () => {
productFilter: 'product_id', productFilter: 'product_id',
warehouseFilter: 'warehouse_id', warehouseFilter: 'warehouse_id',
}, },
persist: true,
storeName: 'movement-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -132,20 +119,19 @@ const MovementTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({ const formik = useFormik<MovementFilterType>({
initialValues: { initialValues: {
product: tableFilterState.productFilter, product_id: null,
warehouse: tableFilterState.warehouseFilter, warehouse_id: null,
}, },
validationSchema: MovementFilterSchema, validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product || undefined, true); updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse || undefined, true); updateFilter('warehouseFilter', values.warehouse_id || '');
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => { onReset: () => {
updateFilter('productFilter', undefined, true); updateFilter('productFilter', '');
updateFilter('warehouseFilter', undefined, true); updateFilter('warehouseFilter', '');
filterModal.closeModal();
}, },
}); });
@@ -176,59 +162,67 @@ const MovementTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => { const handleFilterProductChange = useCallback(
formik.setFieldValue('product', val); (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 handleFilterWarehouseChange = ( const handleFilterWarehouseChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const warehouse = val as OptionType | null;
formik.setFieldValue('warehouse', val); 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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const { const { data: movements, isLoading } = useSWR(
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`, `${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher MovementApi.getAllFetcher
); );
const singleDeleteHandler = async () => { useEffect(() => {
setIsDeleteLoading(true); updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const response = await MovementApi.delete(selectedMovement?.id as number); useEffect(() => {
setTableState('movement-table', pathname);
singleDeleteModal.closeModal(); }, [pathname, setTableState]);
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Movement!');
refreshMovements();
} else {
toast.error(response?.message || 'Failed to delete Movement');
}
};
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const movementColumns: ColumnDef<Movement>[] = useMemo( const movementColumns: ColumnDef<Movement>[] = useMemo(
@@ -281,27 +275,16 @@ const MovementTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
singleDeleteModal.openModal();
};
return ( return (
<RowOptionsMenu <RowOptionsMenu
props={props} props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'} popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/> />
); );
}, },
}, },
], ],
[ [tableFilterState.pageSize, tableFilterState.page]
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
); );
return ( return (
@@ -427,7 +410,7 @@ const MovementTable = () => {
label='Produk' label='Produk'
placeholder='Pilih Produk' placeholder='Pilih Produk'
options={productOptions} options={productOptions}
value={formik.values.product} value={productIdValue}
onChange={handleFilterProductChange} onChange={handleFilterProductChange}
onInputChange={setProductInputValue} onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions} isLoading={isLoadingProductOptions}
@@ -439,7 +422,7 @@ const MovementTable = () => {
label='Gudang' label='Gudang'
placeholder='Pilih Gudang' placeholder='Pilih Gudang'
options={warehouseOptions} options={warehouseOptions}
value={formik.values.warehouse} value={warehouseIdValue}
onChange={handleFilterWarehouseChange} onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions} isLoading={isLoadingWarehouseOptions}
@@ -452,9 +435,13 @@ const MovementTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset' type='button'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -468,21 +455,6 @@ const MovementTable = () => {
</div> </div>
</form> </form>
</Modal> </Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</> </>
); );
}; };
@@ -1,18 +1,11 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const MovementFilterSchema = Yup.object().shape({ export const MovementFilterSchema = object().shape({
product: Yup.object({ product_id: string().nullable(),
value: Yup.string().nullable(), warehouse_id: string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type MovementFilterType = { export type MovementFilterType = {
product?: OptionType<string>; product_id: string | null;
warehouse?: OptionType<string>; warehouse_id: string | null;
}; };
@@ -82,7 +82,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: number; warehouse_id: number;
warehouse_name: string; warehouse_name: string;
quantity: number; quantity: number;
transfer_available_qty?: number;
} }
// ===== USE SELECT HOOKS ===== // ===== USE SELECT HOOKS =====
@@ -324,6 +323,8 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, },
}); });
const { setFieldValue, setFieldTouched, setFieldError } = formik;
const prevSourceWarehouseIdRef = useRef<number | null>( const prevSourceWarehouseIdRef = useRef<number | null>(
formik.values.source_warehouse_id formik.values.source_warehouse_id
); );
@@ -337,14 +338,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId && prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null prevSourceWarehouseId !== null
) { ) {
formik.setFieldValue('products', [ setFieldValue('products', [
{ {
product: null, product: null,
product_id: 0, product_id: 0,
product_qty: '', product_qty: '',
}, },
]); ]);
formik.setFieldTouched('products', false); setFieldTouched('products', false);
const updatedDeliveries = formik.values.deliveries.map( const updatedDeliveries = formik.values.deliveries.map(
(delivery: DeliverySchema) => ({ (delivery: DeliverySchema) => ({
@@ -358,12 +359,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
], ],
}) })
); );
formik.setFieldValue('deliveries', updatedDeliveries); setFieldValue('deliveries', updatedDeliveries);
formik.setFieldTouched('deliveries', false); setFieldTouched('deliveries', false);
} }
prevSourceWarehouseIdRef.current = currentSourceWarehouseId; prevSourceWarehouseIdRef.current = currentSourceWarehouseId;
}, [formik.values.source_warehouse_id, formik.values.deliveries]); }, [
formik.values.source_warehouse_id,
formik.values.deliveries,
setFieldValue,
setFieldTouched,
]);
// ===== PRODUCT WAREHOUSE FETCHING (after form initialization) ===== // ===== PRODUCT WAREHOUSE FETCHING (after form initialization) =====
const { const {
@@ -380,8 +386,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: formik.values.source_warehouse_id warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString() ? formik.values.source_warehouse_id.toString()
: '', : '',
transfer_context: 'inventory_transfer',
stock_mode: 'exclude_chickin',
} }
); );
@@ -394,7 +398,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: pw.warehouse.id, warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name, warehouse_name: pw.warehouse.name,
quantity: pw.quantity, quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
})) }))
: []; : [];
}, [productWarehouses]); }, [productWarehouses]);
@@ -459,9 +462,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
// ===== EVENT HANDLERS ===== // ===== EVENT HANDLERS =====
const handleTransferDateChange = useCallback( const handleTransferDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('transfer_date', e.target.value); setFieldValue('transfer_date', e.target.value);
}, },
[] [setFieldValue]
); );
const handleSourceWarehouseChange = useCallback( const handleSourceWarehouseChange = useCallback(
@@ -481,14 +484,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return; return;
} }
formik.setFieldTouched('source_warehouse', true); setFieldTouched('source_warehouse', true);
formik.setFieldValue('source_warehouse', val); setFieldValue('source_warehouse', val);
formik.setFieldTouched('source_warehouse_id', true); setFieldTouched('source_warehouse_id', true);
formik.setFieldValue('source_warehouse_id', newSourceWarehouseId); setFieldValue('source_warehouse_id', newSourceWarehouseId);
}, },
[ [
formik.values.destination_warehouse_id, formik.values.destination_warehouse_id,
formik.values.destination_warehouse, formik.values.destination_warehouse,
setFieldTouched,
setFieldValue,
] ]
); );
@@ -509,15 +514,17 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return; return;
} }
formik.setFieldTouched('destination_warehouse', true); setFieldTouched('destination_warehouse', true);
formik.setFieldValue('destination_warehouse', val); setFieldValue('destination_warehouse', val);
formik.setFieldTouched('destination_warehouse_id', true); setFieldTouched('destination_warehouse_id', true);
formik.setFieldValue( setFieldValue('destination_warehouse_id', newDestinationWarehouseId);
'destination_warehouse_id',
newDestinationWarehouseId
);
}, },
[formik.values.source_warehouse_id, formik.values.source_warehouse] [
formik.values.source_warehouse_id,
formik.values.source_warehouse,
setFieldTouched,
setFieldValue,
]
); );
const addProduct = useCallback(() => { const addProduct = useCallback(() => {
@@ -529,15 +536,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
product_qty: '', product_qty: '',
}, },
]; ];
formik.setFieldValue('products', newProducts); setFieldValue('products', newProducts);
}, [formik.values.products]); }, [formik.values.products, setFieldValue]);
const removeProduct = useCallback( const removeProduct = useCallback(
(i: number) => { (i: number) => {
const updatedProducts = formik.values.products?.filter( const updatedProducts = formik.values.products?.filter(
(_, idx) => idx !== i (_, idx) => idx !== i
); );
formik.setFieldValue('products', updatedProducts); setFieldValue('products', updatedProducts);
setSelectedProducts([]); setSelectedProducts([]);
@@ -546,7 +553,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setProductQtyErrorShown(false); setProductQtyErrorShown(false);
} }
}, },
[formik.values.products, productQtyErrorShown, setSelectedProducts] [
formik.values.products,
productQtyErrorShown,
setSelectedProducts,
setFieldValue,
]
); );
const bulkRemoveProduct = useCallback(() => { const bulkRemoveProduct = useCallback(() => {
@@ -554,26 +566,32 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.products?.filter( formik.values.products?.filter(
(_, idx) => !selectedProducts.includes(idx) (_, idx) => !selectedProducts.includes(idx)
) ?? []; ) ?? [];
formik.setFieldValue('products', updatedProducts); setFieldValue('products', updatedProducts);
setSelectedProducts([]); setSelectedProducts([]);
if (productQtyErrorShown) { if (productQtyErrorShown) {
toast.dismiss(); toast.dismiss();
setProductQtyErrorShown(false); setProductQtyErrorShown(false);
} }
}, [formik, selectedProducts, setSelectedProducts, productQtyErrorShown]); }, [
selectedProducts,
setSelectedProducts,
productQtyErrorShown,
setFieldValue,
formik.values.products,
]);
const handleProductChange = useCallback( const handleProductChange = useCallback(
(idx: number, val: OptionType | OptionType[] | null) => { (idx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`products.${idx}.product`, true); setFieldTouched(`products.${idx}.product`, true);
formik.setFieldValue(`products.${idx}.product`, val); setFieldValue(`products.${idx}.product`, val);
formik.setFieldTouched(`products.${idx}.product_id`, true); setFieldTouched(`products.${idx}.product_id`, true);
formik.setFieldValue( setFieldValue(
`products.${idx}.product_id`, `products.${idx}.product_id`,
(val as ProductWarehouseOptionType)?.value (val as ProductWarehouseOptionType)?.value
); );
}, },
[] [setFieldTouched, setFieldValue]
); );
const handleProductSelectAllChange = useCallback( const handleProductSelectAllChange = useCallback(
@@ -600,7 +618,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
const addDelivery = useCallback(() => { const addDelivery = useCallback(() => {
formik.setFieldValue('deliveries', [ setFieldValue('deliveries', [
...(formik.values.deliveries || []), ...(formik.values.deliveries || []),
{ {
delivery_cost: '', delivery_cost: '',
@@ -619,14 +637,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
], ],
}, },
]); ]);
}, [formik.values.deliveries]); }, [formik.values.deliveries, setFieldValue]);
const removeDelivery = useCallback( const removeDelivery = useCallback(
(i: number) => { (i: number) => {
const updatedDeliveries = formik.values.deliveries?.filter( const updatedDeliveries = formik.values.deliveries?.filter(
(_, idx) => idx !== i (_, idx) => idx !== i
); );
formik.setFieldValue('deliveries', updatedDeliveries); setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]); setSelectedDeliveries([]);
@@ -635,7 +653,12 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setDeliveryQtyErrorShown(false); setDeliveryQtyErrorShown(false);
} }
}, },
[formik.values.deliveries, deliveryQtyErrorShown, setSelectedDeliveries] [
formik.values.deliveries,
deliveryQtyErrorShown,
setSelectedDeliveries,
setFieldValue,
]
); );
const bulkRemoveDelivery = useCallback(() => { const bulkRemoveDelivery = useCallback(() => {
@@ -643,7 +666,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.deliveries?.filter( formik.values.deliveries?.filter(
(_, idx) => !selectedDeliveries.includes(idx) (_, idx) => !selectedDeliveries.includes(idx)
) ?? []; ) ?? [];
formik.setFieldValue('deliveries', updatedDeliveries); setFieldValue('deliveries', updatedDeliveries);
setSelectedDeliveries([]); setSelectedDeliveries([]);
if (deliveryQtyErrorShown) { if (deliveryQtyErrorShown) {
@@ -651,10 +674,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setDeliveryQtyErrorShown(false); setDeliveryQtyErrorShown(false);
} }
}, [ }, [
formik,
selectedDeliveries, selectedDeliveries,
setSelectedDeliveries, setSelectedDeliveries,
deliveryQtyErrorShown, deliveryQtyErrorShown,
setFieldValue,
formik.values.deliveries,
]); ]);
const handleDeliverySelectAllChange = useCallback( const handleDeliverySelectAllChange = useCallback(
@@ -684,34 +708,28 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const handleDeliveryProductChange = useCallback( const handleDeliveryProductChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => { (deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched( setFieldTouched(`deliveries.${deliveryIdx}.products.0.product`, true);
`deliveries.${deliveryIdx}.products.0.product`, setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
true setFieldTouched(`deliveries.${deliveryIdx}.products.0.product_id`, true);
); setFieldValue(
formik.setFieldValue(`deliveries.${deliveryIdx}.products.0.product`, val);
formik.setFieldTouched(
`deliveries.${deliveryIdx}.products.0.product_id`,
true
);
formik.setFieldValue(
`deliveries.${deliveryIdx}.products.0.product_id`, `deliveries.${deliveryIdx}.products.0.product_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}, },
[] [setFieldTouched, setFieldValue]
); );
const handleDeliverySupplierChange = useCallback( const handleDeliverySupplierChange = useCallback(
(deliveryIdx: number, val: OptionType | OptionType[] | null) => { (deliveryIdx: number, val: OptionType | OptionType[] | null) => {
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true); setFieldTouched(`deliveries.${deliveryIdx}.supplier`, true);
formik.setFieldValue(`deliveries.${deliveryIdx}.supplier`, val); setFieldValue(`deliveries.${deliveryIdx}.supplier`, val);
formik.setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true); setFieldTouched(`deliveries.${deliveryIdx}.supplier_id`, true);
formik.setFieldValue( setFieldValue(
`deliveries.${deliveryIdx}.supplier_id`, `deliveries.${deliveryIdx}.supplier_id`,
(val as OptionType)?.value (val as OptionType)?.value
); );
}, },
[] [setFieldTouched, setFieldValue]
); );
const handleDeliveryDocumentChange = useCallback( const handleDeliveryDocumentChange = useCallback(
@@ -723,15 +741,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
e.target.value = ''; e.target.value = '';
return; return;
} }
formik.setFieldValue(`deliveries.${deliveryIdx}.document`, file); setFieldValue(`deliveries.${deliveryIdx}.document`, file);
} }
}, },
[] [setFieldValue]
); );
const handleDeliveryCostChange = useCallback( const handleDeliveryCostChange = useCallback(
(idx: number, value: number) => { (idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, value); setFieldValue(`deliveries.${idx}.delivery_cost`, value);
const delivery = formik.values.deliveries?.[idx]; const delivery = formik.values.deliveries?.[idx];
if (delivery) { if (delivery) {
@@ -741,21 +759,18 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
if (productQty > 0 && value > 0) { if (productQty > 0 && value > 0) {
const perItem = value / productQty; const perItem = value / productQty;
formik.setFieldValue( setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, perItem);
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} else if (value === 0) { } else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0); setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, 0);
} }
} }
}, },
[formik.values.deliveries] [formik.values.deliveries, setFieldValue]
); );
const handleDeliveryCostPerItemChange = useCallback( const handleDeliveryCostPerItemChange = useCallback(
(idx: number, value: number) => { (idx: number, value: number) => {
formik.setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value); setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, value);
const delivery = formik.values.deliveries?.[idx]; const delivery = formik.values.deliveries?.[idx];
if (delivery) { if (delivery) {
@@ -765,13 +780,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
if (productQty > 0 && value > 0) { if (productQty > 0 && value > 0) {
const totalCost = value * productQty; const totalCost = value * productQty;
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
} else if (value === 0) { } else if (value === 0) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, 0); setFieldValue(`deliveries.${idx}.delivery_cost`, 0);
} }
} }
}, },
[formik.values.deliveries] [formik.values.deliveries, setFieldValue]
); );
const handleDeliveryCostChangeWrapper = useCallback( const handleDeliveryCostChangeWrapper = useCallback(
@@ -838,22 +853,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, [formik.values.products, formik.values.deliveries]); }, [formik.values.products, formik.values.deliveries]);
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
},
[productWarehouseOptions, type]
);
const getTotalStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
@@ -864,16 +863,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
},
[productWarehouseOptions]
);
const getProductQtyBottomLabel = useCallback( const getProductQtyBottomLabel = useCallback(
(productIdx: number) => { (productIdx: number) => {
if (type === 'detail') return undefined; if (type === 'detail') return undefined;
@@ -881,31 +870,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return undefined; if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty; const remainingStock = availableStock - requestedQty;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > 0) { if (requestedQty > 0) {
if (isAyamProduct) {
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Sisa: ${formatNumber(remainingStock)}`; return `Sisa: ${formatNumber(remainingStock)}`;
} }
if (isAyamProduct) {
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Tersedia: ${formatNumber(availableStock)}`; return `Tersedia: ${formatNumber(availableStock)}`;
}, },
[ [formik.values.products, getAvailableStock, type]
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const getDeliveryProductQtyBottomLabel = useCallback( const getDeliveryProductQtyBottomLabel = useCallback(
@@ -967,26 +941,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return null; if (!product || !product.product_id) return null;
const availableStock = getAvailableStock(product.product_id); const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0; const requestedQty = Number(product.product_qty) || 0;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > availableStock) { if (requestedQty > availableStock) {
if (isAyamProduct) {
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
}
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`; return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
} }
return null; return null;
}, },
[ [formik.values.products, getAvailableStock, type]
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
); );
const validateDeliveryQty = useCallback( const validateDeliveryQty = useCallback(
@@ -1100,12 +1063,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
return !validateDeliveryQty(deliveryIdx, productIdx, qty); return !validateDeliveryQty(deliveryIdx, productIdx, qty);
}) })
) ?? []), ) ?? []),
[ [formik.values.deliveries, validateDeliveryQty, type]
formik.values.deliveries,
formik.values.products,
validateDeliveryQty,
type,
]
); );
const hasInvalidQty = useMemo( const hasInvalidQty = useMemo(
@@ -1122,6 +1080,27 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
}, [formik.values.products, getProductQtyError, type]); }, [formik.values.products, getProductQtyError, type]);
const deliveryCostDepString = useMemo(
() =>
formik.values.deliveries
?.map((d, idx) => ({
idx,
productQty: d.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
),
deliveryCost: parseInt((d.delivery_cost || '').toString()) || 0,
deliveryCostPerItem:
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
}))
.map(
(item) =>
`${item.idx}:${item.productQty}:${item.deliveryCost}:${item.deliveryCostPerItem}`
)
.join('|'),
[formik.values.deliveries]
);
// ===== EFFECTS ===== // ===== EFFECTS =====
useEffect(() => { useEffect(() => {
formik.values.deliveries?.forEach((delivery, idx) => { formik.values.deliveries?.forEach((delivery, idx) => {
@@ -1138,36 +1117,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (deliveryCost > 0 && productQty > 0) { if (deliveryCost > 0 && productQty > 0) {
const perItem = deliveryCost / productQty; const perItem = deliveryCost / productQty;
if (Math.abs(deliveryCostPerItem - perItem) > 0.01) { if (Math.abs(deliveryCostPerItem - perItem) > 0.01) {
formik.setFieldValue( setFieldValue(`deliveries.${idx}.delivery_cost_per_item`, perItem);
`deliveries.${idx}.delivery_cost_per_item`,
perItem
);
} }
} else if (deliveryCostPerItem > 0 && productQty > 0) { } else if (deliveryCostPerItem > 0 && productQty > 0) {
const totalCost = deliveryCostPerItem * productQty; const totalCost = deliveryCostPerItem * productQty;
if (Math.abs(deliveryCost - totalCost) > 0.01) { if (Math.abs(deliveryCost - totalCost) > 0.01) {
formik.setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost); setFieldValue(`deliveries.${idx}.delivery_cost`, totalCost);
} }
} }
}); });
}, [ }, [deliveryCostDepString, setFieldValue, formik.values.deliveries]);
formik.values.deliveries
?.map((d, idx) => ({
idx,
productQty: d.products.reduce(
(sum, p) => sum + (parseInt(p.product_qty.toString()) || 0),
0
),
deliveryCost: parseInt((d.delivery_cost || '').toString()) || 0,
deliveryCostPerItem:
parseInt((d.delivery_cost_per_item || '').toString()) || 0,
}))
.map(
(item) =>
`${item.idx}:${item.productQty}:${item.deliveryCost}:${item.deliveryCostPerItem}`
)
.join('|'),
]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -1177,7 +1136,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
!isInitialized !isInitialized
) { ) {
if (formik.values.products.length === 0) { if (formik.values.products.length === 0) {
formik.setFieldValue('products', [ setFieldValue('products', [
{ {
product: null, product: null,
product_id: 0, product_id: 0,
@@ -1186,7 +1145,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
]); ]);
} }
if (formik.values.deliveries.length === 0) { if (formik.values.deliveries.length === 0) {
formik.setFieldValue('deliveries', [ setFieldValue('deliveries', [
{ {
delivery_cost: undefined, delivery_cost: undefined,
delivery_cost_per_item: undefined, delivery_cost_per_item: undefined,
@@ -1208,7 +1167,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
} }
setIsInitialized(true); setIsInitialized(true);
} }
}, [formik.values.source_warehouse_id, isInitialized, type]); }, [
formik.values.source_warehouse_id,
isInitialized,
type,
setFieldValue,
formik.values.products.length,
formik.values.deliveries.length,
]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -1217,7 +1183,7 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.values.source_warehouse_id === formik.values.source_warehouse_id ===
formik.values.destination_warehouse_id formik.values.destination_warehouse_id
) { ) {
formik.setFieldError( setFieldError(
'destination_warehouse_id', 'destination_warehouse_id',
'Gudang tujuan tidak boleh sama dengan gudang asal!' 'Gudang tujuan tidak boleh sama dengan gudang asal!'
); );
@@ -1226,13 +1192,14 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
formik.errors.destination_warehouse_id === formik.errors.destination_warehouse_id ===
'Gudang tujuan tidak boleh sama dengan gudang asal!' 'Gudang tujuan tidak boleh sama dengan gudang asal!'
) { ) {
formik.setFieldError('destination_warehouse_id', undefined); setFieldError('destination_warehouse_id', undefined);
} }
} }
}, [ }, [
formik.values.source_warehouse_id, formik.values.source_warehouse_id,
formik.values.destination_warehouse_id, formik.values.destination_warehouse_id,
formik.errors.destination_warehouse_id, formik.errors.destination_warehouse_id,
setFieldError,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1268,29 +1235,37 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
); );
if (hasChanges) { if (hasChanges) {
formik.setFieldValue('deliveries', updatedDeliveries); setFieldValue('deliveries', updatedDeliveries);
} }
} }
}, [formik.values.products]); }, [formik.values.products, formik.values.deliveries, setFieldValue]);
const productQtyDepString = useMemo(
() => formik.values.products?.map((p) => p.product_qty).join(','),
[formik.values.products]
);
useEffect(() => { useEffect(() => {
if (productQtyErrorShown) { if (productQtyErrorShown) {
toast.dismiss(); toast.dismiss();
setProductQtyErrorShown(false); setProductQtyErrorShown(false);
} }
}, [formik.values.products?.map((p) => p.product_qty).join(',')]); }, [productQtyErrorShown]);
const deliveryProductQtyDepString = useMemo(
() =>
formik.values.deliveries
?.map((d) => d.products.map((p) => p.product_qty).join(','))
.join('|'),
[formik.values.deliveries]
);
useEffect(() => { useEffect(() => {
if (deliveryQtyErrorShown) { if (deliveryQtyErrorShown) {
toast.dismiss(); toast.dismiss();
setDeliveryQtyErrorShown(false); setDeliveryQtyErrorShown(false);
} }
}, [ }, [deliveryProductQtyDepString, productQtyDepString, deliveryQtyErrorShown]);
formik.values.deliveries
?.map((d) => d.products.map((p) => p.product_qty).join(','))
.join('|'),
formik.values.products?.map((p) => p.product_qty).join(','),
]);
useEffect(() => { useEffect(() => {
if (hasExceededStock && !productQtyErrorShown && type !== 'detail') { if (hasExceededStock && !productQtyErrorShown && type !== 'detail') {
@@ -4,23 +4,17 @@ import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Table from '@/components/Table'; import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission'; 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 { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory'; import { InventoryProductApi } from '@/services/api/inventory';
import { ProductCategoryApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product'; import { InventoryProduct } from '@/types/api/inventory/product';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton'; import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
@@ -77,79 +71,25 @@ const RowOptionsMenu = ({
}; };
const InventoryProductTable = () => { const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
categoryFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
categoryFilter: undefined,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR( const { data: inventoryProducts, isLoading } = useSWR(
@@ -157,8 +97,17 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher InventoryProductApi.getAllFetcher
); );
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const columns: ColumnDef<InventoryProduct>[] = useMemo( const columns: ColumnDef<InventoryProduct>[] = useMemo(
@@ -233,163 +182,96 @@ const InventoryProductTable = () => {
); );
return ( return (
<> <div className='w-full'>
<div className='w-full'> {/* Header Section */}
{/* Header Section */} <div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'> {/* Action Buttons */}
{/* Action Buttons */} <div className='w-fit flex flex-row gap-3 flex-wrap'>
<div className='w-fit flex flex-row gap-3 flex-wrap'> <RequirePermission permissions='lti.inventory.product_stock.create'>
<RequirePermission permissions='lti.inventory.product_stock.create'> <Button
<Button href='/inventory/product/add'
href='/inventory/product/add' color='primary'
color='primary' className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
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} />
<Icon icon='heroicons:plus' width={20} height={20} /> Add Product
Add Product </Button>
</Button> </RequirePermission>
</RequirePermission> </div>
</div>
{/* Search and Filter */} {/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'> <div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput <DebouncedTextInput
name='search' name='search'
placeholder='Search' placeholder='Search'
value={tableFilterState.search ?? ''} value={tableFilterState.search ?? ''}
onChange={searchChangeHandler} onChange={searchChangeHandler}
startAdornment={ 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>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
<Icon <Icon
icon='heroicons:magnifying-glass' icon='heroicons:document-text'
className='text-white'
width={20} width={20}
height={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> </div>
</div> ) : (
<Table<InventoryProduct>
{/* Table Section */} data={
<div className='flex flex-col mb-4'> isResponseSuccess(inventoryProducts)
{isLoading ? ( ? inventoryProducts?.data
<div className='w-full flex flex-row justify-center items-center p-4'> : []
<span className='loading loading-spinner loading-xl' /> }
</div> columns={columns}
) : !isResponseSuccess(inventoryProducts) || pageSize={tableFilterState.pageSize}
inventoryProducts.data?.length === 0 ? ( page={
<div className='p-3'> isResponseSuccess(inventoryProducts)
<InventoryProductTableSkeleton ? inventoryProducts?.meta?.page
columns={columns} : 0
icon={ }
<Icon totalItems={
icon='heroicons:document-text' isResponseSuccess(inventoryProducts)
className='text-white' ? inventoryProducts?.meta?.total_results
width={20} : 0
height={20} }
/> onPageChange={setPage}
} onPageSizeChange={setPageSize}
/> isLoading={isLoading}
</div> sorting={sorting}
) : ( setSorting={setSorting}
<Table<InventoryProduct> className={{
data={ containerClassName: cn('p-3 mb-0'),
isResponseSuccess(inventoryProducts) headerColumnClassName: 'text-nowrap',
? 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>
</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,16 +1,8 @@
'use client';
import Card from '@/components/Card'; import Card from '@/components/Card';
import { OptionType } from '@/components/input/SelectInput';
import { FormHeader } from '@/components/helper/form/FormHeader'; 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 StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable'; import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper'; import { formatCurrency, formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product'; import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -19,34 +11,17 @@ const InventoryProductDetail = ({
}: { }: {
inventoryProduct?: InventoryProduct; inventoryProduct?: InventoryProduct;
}) => { }) => {
const filterModal = useModal(); const stockLogs = useMemo(() => {
return (
const { state: filterState, updateFilter } = useTableFilter<{ inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
warehouse_ids: OptionType<number>[]; warehouse.stock_logs.map((log) => ({
}>({ ...log,
initial: { warehouse_name: warehouse.warehouse_name,
warehouse_ids: [], warehouse_id: warehouse.warehouse_id,
}, }))
persist: true, ) || []
storeName: 'inventory-product-stock-log-filter', );
}); }, [inventoryProduct]);
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 ( return (
<div className='flex flex-col gap-4 p-4'> <div className='flex flex-col gap-4 p-4'>
@@ -139,29 +114,7 @@ const InventoryProductDetail = ({
productWarehouseStock={inventoryProduct?.product_warehouses ?? []} productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/> />
<RequirePermission permissions={'lti.inventory.stock_log.list'}> <StockLogTable stockLogs={stockLogs} />
<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> </div>
); );
}; };
@@ -1,115 +0,0 @@
'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,183 +1,95 @@
import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLogApi } from '@/services/api/inventory'; import { StockLog } from '@/types/api/inventory/product';
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 = ({ const StockLogTable = ({
productWarehouse, stockLogs,
}: { }: {
productWarehouse: ProductWarehouseStock; stockLogs: (StockLog & { warehouse_name: string; warehouse_id: number })[];
}) => { }) => {
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 ( return (
<div ref={containerRef}> <Card
<Card title='Informasi Stock Produk'
title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`} collapsible
collapsible variant='bordered'
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',
},
]}
className={{ className={{
wrapper: 'w-full', 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',
}} }}
> />
<div className='flex justify-end px-6 pt-4'> </Card>
<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,42 +1,13 @@
import Card from '@/components/Card'; import Card from '@/components/Card';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock } from '@/types/api/inventory/product'; 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 = ({ const StockProductWarehouseTable = ({
productWarehouseStock, productWarehouseStock,
}: { }: {
productWarehouseStock?: ProductWarehouseStock[]; productWarehouseStock?: ProductWarehouseStock[];
}) => { }) => {
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
return ( return (
<Card <Card
title='Informasi Gudang' title='Informasi Gudang'
@@ -48,14 +19,32 @@ const StockProductWarehouseTable = ({
> >
<Table<ProductWarehouseStock> <Table<ProductWarehouseStock>
data={productWarehouseStock ?? []} data={productWarehouseStock ?? []}
columns={stockProductWarehouseTableColumns} columns={[
pageSize={tableFilterState.pageSize} {
page={tableFilterState.page ?? 0} header: 'Nama Gudang',
totalItems={productWarehouseStock?.length ?? 0} accessorKey: 'warehouse_name',
onPageChange={setPage} },
onPageSizeChange={setPageSize} {
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);
},
},
]}
className={{ className={{
containerClassName: 'mt-6 mb-0', containerClassName: 'mt-6',
tableWrapperClassName: 'overflow-x-auto min-h-full!', tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!', tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200', headerRowClassName: 'border-b border-b-gray-200',
@@ -199,9 +199,6 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD' 'yyyy-MM-DD'
), ),
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
}; };
} }
}) })
@@ -371,9 +368,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
const currentProducts = deliveryOrderValues?.find( const currentProducts = deliveryOrderValues?.find(
(product) => product.id == id (product) => product.id == id
); );
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
if (id) { if (id) {
setStep(2); setStep(2);
} }
@@ -435,9 +430,6 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD' 'yyyy-MM-DD'
), ),
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
}; };
} }
}) })
@@ -544,9 +536,13 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
formModal.closeModal(); formModal.closeModal();
}; };
const hasLoadedInitialValues = useRef(false);
useEffect(() => { useEffect(() => {
const getFilledInitialValues = async () => { const getFilledInitialValues = async () => {
if (marketingId && isResponseSuccess(marketing)) { if (marketingId && isResponseSuccess(marketing)) {
if (hasLoadedInitialValues.current) return;
hasLoadedInitialValues.current = true;
const filledInitialValues = await getFilledMarketingFormInitialValues( const filledInitialValues = await getFilledMarketingFormInitialValues(
marketing.data marketing.data
); );
@@ -590,9 +586,15 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
setFormErrorMessage(''); setFormErrorMessage('');
}, [step]); }, [step]);
// sync delivery order values to formik const prevDeliveryOrderValuesRef = useRef(deliveryOrderValues);
useEffect(() => { useEffect(() => {
formik.setFieldValue('delivery_order', deliveryOrderValues); if (
JSON.stringify(prevDeliveryOrderValuesRef.current) !==
JSON.stringify(deliveryOrderValues)
) {
prevDeliveryOrderValuesRef.current = deliveryOrderValues;
formik.setFieldValue('delivery_order', deliveryOrderValues);
}
}, [deliveryOrderValues]); }, [deliveryOrderValues]);
const grandTotal = useMemo(() => { const grandTotal = useMemo(() => {
@@ -849,11 +851,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold' className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected} disabled={deliveryRejected}
> >
{marketing?.data?.latest_approval?.step_number === 1 && Approve
'Approve'}
{marketing?.data?.latest_approval?.step_number === 2 &&
'Deliver Item'}
</Button> </Button>
</div> </div>
)} )}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject, useCallback, useMemo } from 'react'; import { RefObject, useMemo } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
@@ -10,38 +10,22 @@ import SelectInput, {
useSelect, useSelect,
} from '@/components/input/SelectInput'; } from '@/components/input/SelectInput';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line'; import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
MarketingFilterSchema,
} from '@/components/pages/marketing/filter/MarketingFilter';
import { MarketingFilter } from '@/types/api/marketing/marketing'; import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing'; 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 { interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void; onSubmit?: (values: MarketingFilter) => void;
onReset?: () => 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 = ({ const MarketingFilterModal = ({
ref, ref,
onSubmit, onSubmit,
onReset, onReset,
initialValues,
}: MarketingFilterModal) => { }: MarketingFilterModal) => {
const closeModalHandler = () => { const closeModalHandler = () => {
ref.current?.close(); ref.current?.close();
@@ -49,35 +33,51 @@ const MarketingFilterModal = ({
// ===== OPTIONS ===== // ===== OPTIONS =====
const { const {
options: productsOptions, rawData: productsRawData,
isLoadingOptions: isLoadingProductsOptions, isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue, setInputValue: setProductsInputValue,
loadMore: loadMoreProducts, loadMore: loadMoreProducts,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', { } = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
include_all: 'true', limit: 'limit',
}); });
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
const { const {
options: customersOptions, options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions, isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue, setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers, loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search', { } = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
has_marketing: 'true', limit: 'limit',
}); });
const { const uniqueCustomersOptions = useMemo(() => {
options: projectFlockOptions, const seen = new Set();
rawData: projectFlocksRawData, return customersOptions.filter((customer) => {
isLoadingOptions: isLoadingProjectFlockOptions, if (seen.has(customer.value)) return false;
setInputValue: setProjectFlockInputValue, seen.add(customer.value);
loadMore: loadMoreProjectFlocks, return true;
} = useSelect<ProjectFlock>( });
ProjectFlockApi.basePath, }, [customersOptions]);
'id',
'flock_name',
'search'
);
const statusOptions = [ const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({ ...MARKETING_APPROVAL_LINE.map((item) => ({
@@ -87,30 +87,23 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' }, { value: 'DITOLAK', label: 'Ditolak' },
]; ];
const formik = useFormik<MarketingFilterFormValues>({ const formik = useFormik<{
initialValues: initialValues || { product_ids: OptionType[];
status: OptionType | null;
customer_id: OptionType | null;
}>({
initialValues: {
product_ids: [], product_ids: [],
status: null, status: null,
customer: null, customer_id: null,
project_flock: null,
project_flock_kandang: null,
}, },
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
const formattedValues: MarketingFilter = { const formattedValues = {
...values,
product_ids: values.product_ids.map((item) => Number(item.value)), 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: values.status?.value.toString() || '',
status_name: values.status?.label || '-', customer_id: Number(values.customer_id?.value),
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); onSubmit?.(formattedValues);
@@ -123,58 +116,18 @@ 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) => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]); formik.setFieldValue('product_ids', val as OptionType[]);
}; };
const customerChangeHandler = (val: OptionType | OptionType[] | null) => { const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue( formik.setFieldValue('customer_id', val as OptionType);
'customer',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
}; };
const statusChangeHandler = (val: OptionType | OptionType[] | null) => { const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType); 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 ( return (
<Modal <Modal
ref={ref} ref={ref}
@@ -184,7 +137,7 @@ const MarketingFilterModal = ({
> >
<form <form
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}
onReset={formikResetHandler} onReset={formik.handleReset}
className='w-full flex flex-col' className='w-full flex flex-col'
> >
{/* Modal Header */} {/* Modal Header */}
@@ -234,44 +187,13 @@ const MarketingFilterModal = ({
label='Customer' label='Customer'
isClearable isClearable
placeholder='Pilih customer' placeholder='Pilih customer'
options={customersOptions} options={uniqueCustomersOptions}
isLoading={isLoadingCustomersOptions} isLoading={isLoadingCustomersOptions}
value={formik.values.customer} value={formik.values.customer_id}
onChange={customerChangeHandler} onChange={customerChangeHandler}
onInputChange={setCustomersInputValue} onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers} 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> </div>
{/* Modal Footer */} {/* Modal Footer */}
+74 -592
View File
@@ -2,39 +2,26 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; 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 Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
getErrorMessage,
isResponseError,
isResponseSuccess,
} from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import { import {
MarketingApi, MarketingApi,
SalesOrderApi, SalesOrderApi,
} from '@/services/api/marketing/marketing'; } from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import { import {
BaseSalesOrder, BaseSalesOrder,
Marketing, Marketing,
MarketingFilter, MarketingFilter,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
CellContext,
ColumnDef,
Row,
SortingState,
Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
@@ -167,21 +154,12 @@ const MarketingTable = () => {
); );
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null); const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); 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 router = useRouter();
const deleteModal = useModal(); const deleteModal = useModal();
const confirmationModal = useModal(); const confirmationModal = useModal();
const productsModal = useModal(); const productsModal = useModal();
const deliveryModal = useModal(); const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const exportProgressInputModal = useModal();
const filterModal = useModal(); const filterModal = useModal();
const { const {
@@ -194,17 +172,8 @@ const MarketingTable = () => {
initial: { initial: {
search: '', search: '',
product_ids: '', product_ids: '',
product_names: '',
status: '', status: '',
status_name: '',
customer_id: '', customer_id: '',
customer_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
sort_by: '',
order_by: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -212,43 +181,9 @@ const MarketingTable = () => {
product_ids: 'product_ids', product_ids: 'product_ids',
status: 'status', status: 'status',
customer_id: 'customer_id', 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 ===== // ===== FETCH DATA =====
const { const {
data: marketing, data: marketing,
@@ -263,64 +198,26 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => { const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter( updateFilter(
'product_ids', 'product_ids',
values.product_ids?.map((item) => item.toString()).join(','), values.product_ids?.map((item) => item.toString()).join(',')
true
); );
updateFilter('product_names', values.product_names?.join(',')); updateFilter('status', values.status ? values.status.toString() : '');
updateFilter('status', values.status ? values.status.toString() : '', true);
updateFilter('status_name', values.status_name, true);
updateFilter( updateFilter(
'customer_id', '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] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false); useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isDeliveryLoading, setIsDeliveryLoading] = useState(false);
const filterResetHandler = () => { const filterResetHandler = () => {
updateFilter('product_ids', '', true); updateFilter('product_ids', '');
updateFilter('product_names', '', true); updateFilter('status', '');
updateFilter('status', '', true); updateFilter('customer_id', '');
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 = () => { const approveClickHandler = () => {
setApproveAction('APPROVED'); setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal(); confirmationModal.openModal();
}; };
@@ -329,14 +226,6 @@ const MarketingTable = () => {
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const productsClickHandler = useCallback(
(item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
},
[productsModal]
);
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
selectedItem?.id as number selectedItem?.id as number
@@ -357,226 +246,75 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter( const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()] (row) => rowSelection[row.id.toString()]
); );
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const eligibleSelectedRows = selectedRowsData.filter((row) => { const hasApprovable = selectedRowsData.some(
const approval = row.latest_approval; (row) =>
row.latest_approval.step_number === 1 &&
if (approval.action === 'REJECTED') { row.latest_approval.action !== 'REJECTED'
return false; );
} const hasRejectable = selectedRowsData.some(
(row) =>
if (selectedApprovalStep === null) { row.latest_approval.step_number === 1 &&
return approval.step_number === 1 || approval.step_number === 2; row.latest_approval.action !== 'REJECTED'
} );
return approval.step_number === selectedApprovalStep;
});
const hasApprovable = eligibleSelectedRows.length > 0;
const hasRejectable = eligibleSelectedRows.length > 0;
const disableApprove = !hasApprovable; const disableApprove = !hasApprovable;
const disableReject = !hasRejectable; const disableReject = !hasRejectable;
const idsToProcess = eligibleSelectedRows.map((row) => row.id); const idsToProcess =
const nextApprovalStatus = approveAction === 'APPROVED'
selectedApprovalStep === 1 ? selectedRowsData
? 'SALES_ORDER' .filter((row) => row.latest_approval.step_number === 1)
: selectedApprovalStep === 2 .map((row) => row.id)
? 'DELIVERY_ORDER' : selectedRowsData
: null; .filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
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) => { 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) { if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`); toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal(); confirmationModal.closeModal();
return; return;
} }
if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) { const approveMarketingRes = await SalesOrderApi.bulkApprovals(
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.'); idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal(); confirmationModal.closeModal();
return; toast.success(approveMarketingRes?.message as string);
}
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal();
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({}); setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
} }
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
}; };
const confirmationModalDeliveryClickHandler = async (notes: string) => { const confirmationModalDeliveryClickHandler = async (notes: string) => {
setIsDeliveryLoading(true); const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
try { deliveryModal.closeModal();
const res = await SalesOrderApi.delivery( toast.success(res?.message as string);
selectedItem?.id as number, refreshMarketing?.();
notes router.push(
); `/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
deliveryModal.closeModal(); );
toast.success(res?.message as string);
refreshMarketing?.();
router.push(
`/marketing/detail/delivery-orders/edit?id=${selectedItem?.id}`
);
} finally {
setIsDeliveryLoading(false);
}
}; };
const getRowCanSelect = useCallback( const getRowCanSelect = (row: Row<Marketing>): boolean => {
(row: Row<Marketing>): boolean => { const approval = row.original.latest_approval;
const approval = row.original.latest_approval; return approval?.step_number === 1 && approval?.action !== 'REJECTED';
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 () => { const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true); setIsLoadingExportingToExcel(true);
@@ -586,53 +324,6 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false); 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>[]>(() => { const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [ return [
{ {
@@ -640,22 +331,7 @@ const MarketingTable = () => {
size: 1, size: 1,
header: ({ table }) => { header: ({ table }) => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;
const stepForBulkSelection = const selectableRows = allRows.filter(getRowCanSelect);
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 = const allSelected =
selectableRows.length > 0 && selectableRows.length > 0 &&
@@ -697,7 +373,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'so_number', accessorKey: 'so_do_number',
header: 'No. Order', header: 'No. Order',
cell: (props) => { cell: (props) => {
return props.row.original.do_number return props.row.original.do_number
@@ -713,7 +389,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'status', accessorKey: 'approval.step_name',
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const approval = props.row.original.latest_approval; const approval = props.row.original.latest_approval;
@@ -748,12 +424,10 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'customer', accessorKey: 'customer.name',
header: 'Customer', header: 'Customer',
cell: (props) => props.row.original.customer.name,
}, },
{ {
accessorKey: 'grand_total',
accessorFn: (row) => accessorFn: (row) =>
row.sales_order row.sales_order
?.map((product) => product.total_price) ?.map((product) => product.total_price)
@@ -770,8 +444,12 @@ const MarketingTable = () => {
{ {
accessorKey: 'marketing_products.length', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
enableSorting: false,
cell: (props) => { cell: (props) => {
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
if (props?.row?.original?.sales_order?.length) { if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) { if (props?.row?.original?.sales_order?.length > 1) {
return ( return (
@@ -792,14 +470,6 @@ const MarketingTable = () => {
} }
}, },
}, },
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM yyyy')
: '-',
},
{ {
id: 'actions', id: 'actions',
maxSize: 80, maxSize: 80,
@@ -834,13 +504,7 @@ const MarketingTable = () => {
}, },
}, },
]; ];
}, [ }, [deleteModal, deliveryModal, setSelectedItem, productsModal]);
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
return ( return (
<> <>
@@ -863,7 +527,7 @@ const MarketingTable = () => {
</RequirePermission> </RequirePermission>
{idsToProcess.length > 0 && ( {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 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>
<RequirePermission permissions='lti.marketing.sales_order.approve'> <RequirePermission permissions='lti.marketing.sales_order.approve'>
<Button <Button
color='error' color='error'
@@ -877,7 +541,7 @@ const MarketingTable = () => {
width={20} width={20}
height={20} height={20}
/> />
Reject ({idsToProcess.length} Item) Reject
</Button> </Button>
</RequirePermission> </RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'> <RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -893,7 +557,7 @@ const MarketingTable = () => {
width={20} width={20}
height={20} height={20}
/> />
Approve ({idsToProcess.length} Item) Approve
</Button> </Button>
</RequirePermission> </RequirePermission>
</> </>
@@ -902,18 +566,7 @@ const MarketingTable = () => {
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={tableFilterState} values={tableFilterState}
excludeFields={[ excludeFields={['page', 'pageSize', 'search']}
'page',
'pageSize',
'search',
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'sort_by',
'order_by',
]}
onClick={() => { onClick={() => {
filterModal.openModal(); filterModal.openModal();
}} }}
@@ -959,17 +612,7 @@ const MarketingTable = () => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
> >
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel 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> </Button>
</Dropdown> </Dropdown>
</div> </div>
@@ -1003,9 +646,6 @@ const MarketingTable = () => {
columns={columns} columns={columns}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
totalItems={ totalItems={
isResponseSuccess(marketing) isResponseSuccess(marketing)
? marketing?.meta?.total_results ? marketing?.meta?.total_results
@@ -1037,16 +677,14 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={confirmationModal.ref} ref={confirmationModal.ref}
type={approveAction === 'APPROVED' ? 'success' : 'error'} type={approveAction === 'APPROVED' ? 'success' : 'error'}
text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan tahap ${selectedApprovalStep ?? '-'} (${idsToProcess.length} data)?`} text={`Apakah anda yakin ingin ${approveAction == 'APPROVED' ? 'approve' : 'reject'} data penjualan (${idsToProcess.length} data)?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isApproveLoading,
onClick: confirmationModal.closeModal, onClick: confirmationModal.closeModal,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: approveAction === 'APPROVED' ? 'success' : 'error', color: approveAction === 'APPROVED' ? 'success' : 'error',
isLoading: isApproveLoading,
onClick: approveMarketingHandler, onClick: approveMarketingHandler,
}} }}
/> />
@@ -1070,169 +708,14 @@ const MarketingTable = () => {
text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`} text={`Apakah anda yakin ingin deliver penjualan ${selectedItem?.so_number}?`}
secondaryButton={{ secondaryButton={{
text: 'Tidak', text: 'Tidak',
isLoading: isDeliveryLoading,
}} }}
primaryButton={{ primaryButton={{
text: 'Ya', text: 'Ya',
color: 'success', color: 'success',
isLoading: isDeliveryLoading,
onClick: confirmationModalDeliveryClickHandler, onClick: confirmationModalDeliveryClickHandler,
}} }}
/> />
<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 <Modal
ref={productsModal.ref} ref={productsModal.ref}
className={{ className={{
@@ -1263,7 +746,7 @@ const MarketingTable = () => {
} }
columns={[ columns={[
{ {
header: 'Gudang Fisik', header: 'Kandang',
accessorFn(row) { accessorFn(row) {
return row.product_warehouse.warehouse.name; return row.product_warehouse.warehouse.name;
}, },
@@ -1294,7 +777,6 @@ const MarketingTable = () => {
ref={filterModal.ref} ref={filterModal.ref}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/> />
</> </>
); );
@@ -195,9 +195,7 @@ const SalesOrderFormModal = ({
product.marketing_type?.value?.toLowerCase() === 'telur' product.marketing_type?.value?.toLowerCase() === 'telur'
? convertionUnitValue === 'PETI' ? convertionUnitValue === 'PETI'
? 'PETI' ? 'PETI'
: convertionUnitValue === 'QTY' : 'KG' // termasuk "QTY" dan "KG"
? 'QTY'
: 'KG'
: undefined; : undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM" // Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
@@ -209,6 +207,7 @@ const SalesOrderFormModal = ({
return { return {
vehicle_number: product.vehicle_number as string, vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number, product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(String(product.unit_price || 0)), unit_price: parseFloat(String(product.unit_price || 0)),
total_weight: parseFloat(String(product.total_weight || 0)), total_weight: parseFloat(String(product.total_weight || 0)),
@@ -246,7 +245,6 @@ const SalesOrderFormModal = ({
}) })
.filter((item) => Boolean(item)), .filter((item) => Boolean(item)),
} as UpdateDeliveryOrderPayload); } as UpdateDeliveryOrderPayload);
switch (modalAction) { switch (modalAction) {
case 'add': case 'add':
await createMarketingHandler(payload as CreateSalesOrderPayload); await createMarketingHandler(payload as CreateSalesOrderPayload);
@@ -262,7 +260,11 @@ const SalesOrderFormModal = ({
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, setFormErrorList, close, handleFormSubmit } = const { formErrorList, setFormErrorList, close, handleFormSubmit } =
useFormikErrorList(formik); useFormikErrorList(formik, {
onAfterSubmit: () => {
router.push('/marketing');
},
});
// ================== FORM REPEATER HANDLER ================== // ================== FORM REPEATER HANDLER ==================
const createMarketingHandler = async (values: CreateSalesOrderPayload) => { const createMarketingHandler = async (values: CreateSalesOrderPayload) => {
@@ -456,9 +458,13 @@ const SalesOrderFormModal = ({
); );
}, [memoSalesOrder]); }, [memoSalesOrder]);
const hasLoadedInitialValues = useRef(false);
useEffect(() => { useEffect(() => {
const getFilledInitialValues = async () => { const getFilledInitialValues = async () => {
if (marketingId && isResponseSuccess(marketing)) { if (marketingId && isResponseSuccess(marketing)) {
if (hasLoadedInitialValues.current) return;
hasLoadedInitialValues.current = true;
const filledInitialValues = await getFilledMarketingFormInitialValues( const filledInitialValues = await getFilledMarketingFormInitialValues(
marketing.data marketing.data
); );
@@ -1,18 +0,0 @@
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
@@ -13,7 +13,6 @@ import {
Marketing, Marketing,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import { formatDate, formatTitleCase } from '@/lib/helper'; import { formatDate, formatTitleCase } from '@/lib/helper';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type MarketingSchemaType = { type MarketingSchemaType = {
customer_id: number | undefined; customer_id: number | undefined;
@@ -71,14 +70,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!') .required('Pengiriman wajib diisi!')
.test( .test(
'at-least-one-valid-row', 'at-least-one-valid-row',
'Seluruh data pengiriman harus diisi lengkap!', 'Minimal harus ada satu baris pengiriman yang lengkap diisi!',
function (items) { function (items) {
if (!items || items.length === 0) return false; if (!items || items.length === 0) return false;
// VALIDASI: seluruh item harus valid full // VALIDASI: minimal 1 item valid full
const itemSchema = DeliveryOrderProductSchema; const itemSchema = DeliveryOrderProductSchema;
const hasValidItem = items.every((item) => { const hasValidItem = items.some((item) => {
if (!item) return false; if (!item) return false;
return itemSchema.isValidSync(item, { abortEarly: true }); return itemSchema.isValidSync(item, { abortEarly: true });
}); });
@@ -98,21 +97,17 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
export const SalesProductToFieldValues = ( export const SalesProductToFieldValues = (
product: BaseSalesOrder product: BaseSalesOrder
): SalesOrderProductFormValues => { ): SalesOrderProductFormValues => {
const warehouseOption = {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
};
return { return {
id: product.id, id: product.id,
vehicle_number: product.vehicle_number, vehicle_number: product.vehicle_number,
warehouse_id: product.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: product.product_warehouse.warehouse.id, kandang_id: product.product_warehouse.warehouse.id,
kandang: warehouseOption, kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: { product_warehouse: {
value: product.product_warehouse.id, value: product.product_warehouse.id,
label: getProductWarehouseOptionLabel(product.product_warehouse), label: product.product_warehouse.product.name,
}, },
product_warehouse_data: product.product_warehouse, product_warehouse_data: product.product_warehouse,
product_warehouse_id: product.product_warehouse.id, product_warehouse_id: product.product_warehouse.id,
@@ -123,17 +118,8 @@ export const SalesProductToFieldValues = (
total_price: product.total_price, total_price: product.total_price,
marketing_type: product.marketing_type marketing_type: product.marketing_type
? { ? {
value: value: product.marketing_type,
product.marketing_type === 'AYAM' || label: formatTitleCase(product.marketing_type),
product.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: product.marketing_type,
label: formatTitleCase(
product.marketing_type === 'AYAM' ||
product.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: product.marketing_type
),
} }
: null, : null,
convertion_unit: product.convertion_unit convertion_unit: product.convertion_unit
@@ -153,36 +139,11 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => { ): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => { const data = delivery.deliveries.map((item) => {
const salesOrder = const soId = salesOrders.find(
salesOrders.find((so) => so.id === item.marketing_product_id) ?? (so) => so.product_warehouse.id === item.product_warehouse.id
salesOrders.find( )?.id;
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const warehouseOption = {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
};
const initialSisaBerat =
item?.total_weight &&
salesOrder?.weight_per_convertion &&
salesOrder?.total_peti
? Number(item.total_weight) -
Number(salesOrder.weight_per_convertion) *
Number(salesOrder.total_peti)
: 0;
const initialPricePerConvertion =
item?.total_price &&
salesOrder?.total_peti &&
Number(salesOrder.total_peti) !== 0
? (Number(item.total_price) -
initialSisaBerat * Number(item.unit_price || 0)) /
Number(salesOrder.total_peti)
: Number(item?.unit_price || 0);
return { return {
id: salesOrder?.id, id: soId,
unit_price: item.unit_price, unit_price: item.unit_price,
total_weight: item.total_weight, total_weight: item.total_weight,
qty: item.qty, qty: item.qty,
@@ -191,40 +152,19 @@ export const DeliveryProductToFieldValues = (
vehicle_number: item.vehicle_number, vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'), delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number, do_number: delivery.do_number,
marketing_product_id: item.marketing_product_id ?? salesOrder?.id, marketing_product_id: soId,
marketing_type: salesOrder?.marketing_type
? {
value:
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: salesOrder?.marketing_type,
label: formatTitleCase(
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: salesOrder?.marketing_type
),
}
: null,
convertion_unit: salesOrder?.convertion_unit
? {
value: salesOrder?.convertion_unit.toLowerCase(),
label: formatTitleCase(salesOrder?.convertion_unit),
}
: null,
marketing_product: { marketing_product: {
id: item.marketing_product_id ?? salesOrder?.id, id: soId,
vehicle_number: item.vehicle_number, vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: item.product_warehouse.warehouse.id, kandang_id: item.product_warehouse.warehouse.id,
kandang: warehouseOption, kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: { product_warehouse: {
value: item.product_warehouse.id, value: item.product_warehouse.id,
label: getProductWarehouseOptionLabel(item.product_warehouse), label: item.product_warehouse.product.name,
}, },
product_warehouse_data: item.product_warehouse,
product_warehouse_id: item.product_warehouse.id, product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price, unit_price: item.unit_price,
total_weight: item.total_weight, total_weight: item.total_weight,
@@ -232,13 +172,8 @@ export const DeliveryProductToFieldValues = (
avg_weight: item.avg_weight, avg_weight: item.avg_weight,
total_price: item.total_price, total_price: item.total_price,
}, },
total_peti: salesOrder?.total_peti,
weight_per_convertion:
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
price_per_convertion: initialPricePerConvertion,
} as DeliveryOrderProductFormValues; } as DeliveryOrderProductFormValues;
}); });
return data; return data;
}; };
export const mergeSOwithDO = ( export const mergeSOwithDO = (
@@ -246,25 +181,10 @@ export const mergeSOwithDO = (
deliveryOrders: DeliveryOrderProductFormValues[], deliveryOrders: DeliveryOrderProductFormValues[],
autofill?: boolean autofill?: boolean
): DeliveryOrderProductFormValues[] => { ): DeliveryOrderProductFormValues[] => {
const hasDeliveryOrders = deliveryOrders.length > 0;
return salesOrders.map((so) => { return salesOrders.map((so) => {
const delivery = deliveryOrders.find( const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id (d) => d?.marketing_product_id === so.id
); );
const isTelurQty =
so.marketing_type?.value?.toLowerCase() === 'telur' &&
so.convertion_unit?.value?.toLowerCase() === 'qty';
const salesOrderUnitPrice =
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
? Number(so.total_price) / Number(so.qty)
: so.unit_price;
const salesOrderPricePerQty =
isTelurQty &&
Number(so.total_price || 0) > 0 &&
Number(so.total_weight || 0) > 0
? Number(so.total_price) / Number(so.total_weight)
: so.price_per_qty;
return { return {
...so, // nilai dasar dari sales order ...so, // nilai dasar dari sales order
@@ -272,50 +192,30 @@ export const mergeSOwithDO = (
delivery_date: delivery?.delivery_date || undefined, delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined, do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number, vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price: unit_price: autofill ? so.unit_price : delivery?.unit_price,
autofill && hasDeliveryOrders total_weight: autofill ? so.total_weight : delivery?.total_weight,
? delivery?.unit_price qty: autofill ? so.qty : delivery?.qty,
: salesOrderUnitPrice, avg_weight: autofill ? so.avg_weight : delivery?.avg_weight,
total_weight: total_price: autofill ? so.total_price : delivery?.total_price,
autofill && hasDeliveryOrders
? delivery?.total_weight
: so.total_weight,
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
avg_weight:
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
total_price:
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
marketing_product: so, // jika ada, override marketing_product: so, // jika ada, override
uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom, uom: autofill ? so.uom : delivery?.uom,
weight_per_convertion: weight_per_convertion: autofill
autofill && hasDeliveryOrders ? so.weight_per_convertion
? delivery?.weight_per_convertion : delivery?.weight_per_convertion,
: so.weight_per_convertion, price_per_convertion: autofill
price_per_convertion: ? so.price_per_convertion
autofill && hasDeliveryOrders : delivery?.price_per_convertion,
? delivery?.price_per_convertion convertion_unit: autofill
: so.price_per_convertion, ? so.convertion_unit
convertion_unit: : delivery?.convertion_unit,
autofill && hasDeliveryOrders marketing_type: autofill ? so.marketing_type : delivery?.marketing_type,
? delivery?.convertion_unit total_peti: autofill ? so.total_peti : delivery?.total_peti,
: so.convertion_unit, price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty,
marketing_type: sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat,
autofill && hasDeliveryOrders price_sisa_berat: autofill
? delivery?.marketing_type ? so.price_sisa_berat
: so.marketing_type, : delivery?.price_sisa_berat,
total_peti: week: autofill ? so.week : delivery?.week,
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
price_per_qty:
autofill && hasDeliveryOrders
? delivery?.price_per_qty
: salesOrderPricePerQty,
sisa_berat:
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
price_sisa_berat:
autofill && hasDeliveryOrders
? delivery?.price_sisa_berat
: so.price_sisa_berat,
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
} as DeliveryOrderProductFormValues; } as DeliveryOrderProductFormValues;
}); });
}; };
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
DeliveryOrderProductFormValues, DeliveryOrderProductFormValues,
DeliveryOrderProductSchema, DeliveryOrderProductSchema,
@@ -32,63 +32,6 @@ import Dropdown from '@/components/Dropdown';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { handleMarketingCalculation } from '@/lib/marketing-calculation'; import { handleMarketingCalculation } from '@/lib/marketing-calculation';
type PricingOption =
| string
| {
value: string;
label: string;
}
| null
| undefined;
type PricingSource =
| {
marketing_type?: PricingOption;
convertion_unit?: PricingOption;
total_price?: string | number | null;
qty?: string | number | null;
total_weight?: string | number | null;
unit_price?: string | number | null;
price_per_qty?: number | null;
}
| null
| undefined;
const getOptionValue = (value?: PricingOption) => {
if (!value) return undefined;
if (typeof value === 'string') return value.toLowerCase();
return value.value?.toLowerCase();
};
const isTelurQtyProduct = (value?: PricingSource) =>
getOptionValue(value?.marketing_type) === 'telur' &&
getOptionValue(value?.convertion_unit) === 'qty';
const getDisplayedUnitPrice = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.qty || 0) > 0
) {
return Number(value?.total_price) / Number(value?.qty);
}
return value?.unit_price ?? undefined;
};
const getDisplayedPricePerQty = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.total_weight || 0) > 0
) {
return Number(value?.total_price) / Number(value?.total_weight);
}
return value?.price_per_qty ?? null;
};
const DeliveryOrderProductForm = ({ const DeliveryOrderProductForm = ({
formState, formState,
salesOrders, salesOrders,
@@ -133,7 +76,7 @@ const DeliveryOrderProductForm = ({
? (Number(initialValues.total_price) - ? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) / initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti) Number(initialValues.total_peti)
: Number(initialValues?.unit_price || 0); : 0;
const initialPriceSisaBerat = const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti initialValues?.total_price && initialValues?.total_peti
@@ -146,6 +89,15 @@ const DeliveryOrderProductForm = ({
); );
// ============ Fetch Data ============ // ============ Fetch Data ============
const { data: productData } = useSWR(
selectedProduct?.value
? ProductApi.basePath + '/' + selectedProduct?.value
: null,
() =>
selectedProduct?.value
? ProductApi.getSingle(Number(selectedProduct?.value))
: undefined
);
// Options Week dari minggu 1 - 22 // Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => { // const optionsWeek = useMemo(() => {
@@ -160,7 +112,7 @@ const DeliveryOrderProductForm = ({
if (!Boolean(item.qty)) { if (!Boolean(item.qty)) {
return { return {
value: item.id, value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`, label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
} as OptionType; } as OptionType;
} else { } else {
return null; return null;
@@ -181,19 +133,12 @@ const DeliveryOrderProductForm = ({
const deliveryOrder = useMemo(() => { const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null; if (!hasDeliveryOrder || !deliveryOrders) return null;
const marketingProductId =
initialValues?.marketing_product_id ?? initialValues?.id;
for (const doItem of deliveryOrders) { for (const doItem of deliveryOrders) {
const found = const found = doItem.deliveries.find(
doItem.deliveries.find( (d) =>
(d) => d.marketing_product_id === marketingProductId d.product_warehouse.id ===
) ?? initialValues?.marketing_product?.product_warehouse_id
doItem.deliveries.find( );
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) { if (found) {
return { return {
...found, ...found,
@@ -209,27 +154,6 @@ const DeliveryOrderProductForm = ({
(item) => item.id === initialValues?.marketing_product_id (item) => item.id === initialValues?.marketing_product_id
); );
const defaultPricingSource: PricingSource = {
marketing_type:
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
convertion_unit:
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
total_price:
deliveryOrder?.total_price ??
initialValues?.total_price ??
salesOrder?.total_price,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
total_weight:
deliveryOrder?.total_weight ??
initialValues?.total_weight ??
salesOrder?.total_weight,
unit_price:
deliveryOrder?.unit_price ??
initialValues?.unit_price ??
salesOrder?.unit_price,
price_per_qty: initialValues?.price_per_qty ?? null,
};
const formik = useFormik<DeliveryOrderProductFormValues>({ const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
@@ -243,7 +167,8 @@ const DeliveryOrderProductForm = ({
undefined, undefined,
marketing_product_id: marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined, salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: getDisplayedUnitPrice(defaultPricingSource), unit_price:
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
total_weight: total_weight:
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined, deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined, qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
@@ -261,7 +186,7 @@ const DeliveryOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null, convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null, marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null, total_peti: initialValues?.total_peti ?? null,
price_per_qty: getDisplayedPricePerQty(defaultPricingSource), price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat, sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat, price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null, week: initialValues?.week ?? null,
@@ -299,6 +224,8 @@ const DeliveryOrderProductForm = ({
}, },
}); });
const { resetForm } = formik;
const hasWeekField = useMemo(() => { const hasWeekField = useMemo(() => {
const marketingType = formik.values.marketing_type?.value?.toLowerCase(); const marketingType = formik.values.marketing_type?.value?.toLowerCase();
if (marketingType === 'ayam_pullet') { if (marketingType === 'ayam_pullet') {
@@ -318,9 +245,9 @@ const DeliveryOrderProductForm = ({
return false; return false;
}, [formik.values.marketing_product, formik.values.marketing_type]); }, [formik.values.marketing_product, formik.values.marketing_type]);
const handleResetForm = () => { const handleResetForm = useCallback(() => {
setFormErrorMessage(''); setFormErrorMessage('');
formik.resetForm({ resetForm({
values: { values: {
delivery_date: '', delivery_date: '',
vehicle_number: '', vehicle_number: '',
@@ -344,17 +271,20 @@ const DeliveryOrderProductForm = ({
}, },
}); });
// setSelectedProduct(null); // setSelectedProduct(null);
}; }, [resetForm]);
const handleBlurField = (field: string) => { const handleBlurField = useCallback(
setCurrentInput(field); (field: string) => {
setCurrentInput(field);
handleMarketingCalculation(field, { handleMarketingCalculation(field, {
values: formik.values, values: formik.values,
setFieldValue: formik.setFieldValue, setFieldValue: formik.setFieldValue,
hasSisaBerat, hasSisaBerat,
}); });
}; },
[formik.values, formik.setFieldValue, hasSisaBerat]
);
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty) // Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
const handleFieldChange = ( const handleFieldChange = (
@@ -399,28 +329,25 @@ const DeliveryOrderProductForm = ({
const { setValues: setFormikValues } = formik; const { setValues: setFormikValues } = formik;
const processedInitialValuesRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
if ( if (processedInitialValuesRef.current === initialValues.id) return;
!Boolean(initialValues.qty) && processedInitialValuesRef.current = initialValues.id as number;
!Boolean(initialValues.marketing_product_id)
) { if (!Boolean(initialValues.qty)) {
handleResetForm(); handleResetForm();
} else { } else {
setFormikValues({ setFormikValues(initialValues);
...initialValues,
unit_price: getDisplayedUnitPrice(initialValues),
price_per_qty: getDisplayedPricePerQty(initialValues),
});
if (initialValues?.marketing_product_id) { if (initialValues?.marketing_product_id) {
setSelectedProduct({ setSelectedProduct({
value: initialValues?.marketing_product_id, value: initialValues?.id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`, label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
} as OptionType); } as OptionType);
} }
} }
} }
}, [initialValues]); }, [handleResetForm, initialValues, setFormikValues]);
// ===== Formik Error List ===== // ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList( const { formErrorList, close, handleFormSubmit } = useFormikErrorList(
@@ -431,16 +358,17 @@ const DeliveryOrderProductForm = ({
handleBlurField(currentInput); handleBlurField(currentInput);
formik.setFieldValue( formik.setFieldValue(
'uom', 'uom',
initialValues?.marketing_product?.product_warehouse_data?.product?.uom isResponseSuccess(productData) ? productData?.data?.uom?.name : ''
?.name ?? ''
); );
}, },
} }
); );
useEffect(() => { useEffect(() => {
handleBlurField('week'); if (formik.values.week) {
}, [formik.values.week]); handleBlurField('week');
}
}, [formik.values.week, handleBlurField]);
return ( return (
<> <>
@@ -541,11 +469,10 @@ const DeliveryOrderProductForm = ({
marketing_product_id: selected.value as number, marketing_product_id: selected.value as number,
marketing_product: soFieldValues, marketing_product: soFieldValues,
qty: so.qty, qty: so.qty,
unit_price: getDisplayedUnitPrice(so), unit_price: so.unit_price,
total_price: so.total_price, total_price: so.total_price,
avg_weight: so.avg_weight, avg_weight: so.avg_weight,
total_weight: so.total_weight, total_weight: so.total_weight,
price_per_qty: getDisplayedPricePerQty(so),
vehicle_number: so.vehicle_number, vehicle_number: so.vehicle_number,
week: soFieldValues.week ?? null, week: soFieldValues.week ?? null,
}); });
@@ -556,11 +483,7 @@ const DeliveryOrderProductForm = ({
text={ text={
exisitingValues?.find( exisitingValues?.find(
(item) => item.id === selectedProduct?.value (item) => item.id === selectedProduct?.value
)?.marketing_product?.warehouse?.label ?? )?.marketing_product?.kandang?.label ?? ''
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ??
''
} }
color='success' color='success'
className={{ className={{
@@ -726,7 +649,7 @@ const DeliveryOrderProductForm = ({
placeholder='Masukan Total Peti' placeholder='Masukan Total Peti'
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Peti</span> <span className='text-sm text-base-content/50'>Kg</span>
</div> </div>
} }
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`} bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -776,9 +699,6 @@ const DeliveryOrderProductForm = ({
} }
errorMessage={formik.errors.total_weight} errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot' placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/> />
)} )}
@@ -805,8 +725,9 @@ const DeliveryOrderProductForm = ({
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'> <span className='text-sm text-gray-500'>
{initialValues?.marketing_product?.product_warehouse_data {isResponseSuccess(productData)
?.product?.uom?.name ?? ''} ? productData?.data?.uom.name
: ''}
</span> </span>
</div> </div>
} }
@@ -817,8 +738,9 @@ const DeliveryOrderProductForm = ({
(item) => item.id === formik.values.marketing_product_id (item) => item.id === formik.values.marketing_product_id
)?.qty + )?.qty +
' ' + ' ' +
(initialValues?.marketing_product?.product_warehouse_data (isResponseSuccess(productData)
?.product?.uom?.name ?? '') ? productData?.data?.uom.name
: '')
: '' : ''
} }
/> />
@@ -846,32 +768,12 @@ const DeliveryOrderProductForm = ({
/> />
)} )}
{/* Harga Satuan */} {/* Harga per butir untuk TELUR + QTY */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' && {formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && ( formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput <NumberInput
required required
label='Harga / Kg (Rp)' label='Harga / Butir (Rp)'
name='price_per_qty' name='price_per_qty'
value={formik.values.price_per_qty ?? undefined} value={formik.values.price_per_qty ?? undefined}
onChange={(e) => { onChange={(e) => {
@@ -885,7 +787,27 @@ const DeliveryOrderProductForm = ({
Boolean(formik.errors.price_per_qty) Boolean(formik.errors.price_per_qty)
} }
errorMessage={formik.errors.price_per_qty} errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg' placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/> />
)} )}
@@ -3,11 +3,6 @@ import * as Yup from 'yup';
type SalesOrderProductSchemaType = { type SalesOrderProductSchemaType = {
id?: number | undefined; id?: number | undefined;
warehouse_id?: number;
warehouse?: {
value: number;
label: string;
} | null;
kandang_id?: number; kandang_id?: number;
kandang?: { kandang?: {
value: number; value: number;
@@ -49,22 +44,15 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
Yup.object({ Yup.object({
id: Yup.number(), id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'), vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
warehouse: Yup.object({
value: Yup.number()
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
label: Yup.string().required('Gudang fisik wajib diisi!'),
}).nullable(),
warehouse_id: Yup.number()
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
kandang: Yup.object({ kandang: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number()
label: Yup.string().required(), .min(1, 'Kandang wajib diisi!')
}) .required('Kandang wajib diisi!'),
.nullable() label: Yup.string().required('Kandang wajib diisi!'),
.optional(), }).nullable(),
kandang_id: Yup.number().optional(), kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
product_warehouse: Yup.object({ product_warehouse: Yup.object({
value: Yup.number() value: Yup.number()
.min(1, 'Produk wajib diisi!') .min(1, 'Produk wajib diisi!')
@@ -5,9 +5,9 @@ import {
SalesOrderProductFormValues, SalesOrderProductFormValues,
SalesOrderProductSchema, SalesOrderProductSchema,
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema'; } from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useEffect, useMemo, useState } from 'react'; import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data'; import { WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
@@ -31,7 +31,6 @@ import {
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import { handleMarketingCalculation } from '@/lib/marketing-calculation'; import { handleMarketingCalculation } from '@/lib/marketing-calculation';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
const SalesOrderProductForm = ({ const SalesOrderProductForm = ({
initialValues, initialValues,
@@ -68,25 +67,7 @@ const SalesOrderProductForm = ({
? (Number(initialValues.total_price) - ? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) / initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti) Number(initialValues.total_peti)
: Number(initialValues?.unit_price || 0); : 0;
const isInitialTelurQty =
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
const initialUnitPrice =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.qty || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.qty)
: initialValues?.unit_price || '';
const initialPricePerQty =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.total_weight || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
: (initialValues?.price_per_qty ?? null);
const initialPriceSisaBerat = const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti initialValues?.total_price && initialValues?.total_peti
@@ -103,15 +84,11 @@ const SalesOrderProductForm = ({
enableReinitialize: true, enableReinitialize: true,
initialValues: { initialValues: {
vehicle_number: initialValues?.vehicle_number || '', vehicle_number: initialValues?.vehicle_number || '',
warehouse_id:
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
kandang_id: initialValues?.kandang_id || undefined, kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || null, kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || null, product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_data: initialValues?.product_warehouse_data || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined, product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialUnitPrice, unit_price: initialValues?.unit_price || '',
total_weight: initialValues?.total_weight || '', total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || '', qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || '', avg_weight: initialValues?.avg_weight || '',
@@ -125,7 +102,7 @@ const SalesOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null, convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null, marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null, total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialPricePerQty, price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat, sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat, price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null, week: initialValues?.week ?? null,
@@ -155,11 +132,11 @@ const SalesOrderProductForm = ({
// ===== Options ===== // ===== Options =====
const { const {
options: warehouseOptions, options: kandangSourceOptions,
isLoadingOptions: isLoadingWarehouseOptions, isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setWarehouseSearchValue, setInputValue: setKandangInputValue,
loadMore: loadMoreWarehouses, loadMore: loadMoreKandang,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name'); } = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
// Options Week dari minggu 1 - 22 // Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => { // const optionsWeek = useMemo(() => {
@@ -170,6 +147,7 @@ const SalesOrderProductForm = ({
// }, []); // }, []);
const { const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData, rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions, isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue, setInputValue: setWarehouseInputValue,
@@ -178,69 +156,32 @@ const SalesOrderProductForm = ({
ProductWarehouseApi.basePath, ProductWarehouseApi.basePath,
'id', 'id',
'product.name', 'product.name',
'search', '',
{ {
limit: '100', warehouse_id: formik.values.kandang_id?.toString() ?? '',
available_only: 'true',
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '', type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
} }
); );
const productOptionsFiltered = useMemo(() => { const productOptionsFiltered = useMemo(() => {
if (!isResponseSuccess(warehouseSourceRawData)) { return warehouseSourceOptions.filter(
return initialValues?.product_warehouse (product) =>
? [initialValues.product_warehouse] !exisitingValues
: []; ?.map((item) => item.product_warehouse_id)
} .includes(product.value)
const selectedProductIds = new Set(
exisitingValues
?.filter((item) => item.id !== initialValues?.id)
.map((item) => Number(item.product_warehouse_id))
.filter((item) => item > 0) ?? []
); );
}, [warehouseSourceOptions, exisitingValues]);
const options = warehouseSourceRawData.data
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
.map((item: ProductWarehouse) => ({
value: item.id,
label: getProductWarehouseOptionLabel(item),
}));
if (
initialValues?.product_warehouse &&
initialValues?.product_warehouse_id
) {
const exists = options.find(
(option) =>
Number(option.value) === Number(initialValues.product_warehouse_id)
);
if (!exists) {
options.push(initialValues.product_warehouse);
}
}
return options;
}, [warehouseSourceRawData, exisitingValues, initialValues]);
// ===== Handler ===== // ===== Handler =====
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
const warehouse = (val as OptionType | null) ?? null; formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('warehouse', warehouse);
formik.setFieldValue('warehouse_id', warehouse?.value);
formik.setFieldValue('kandang', warehouse);
formik.setFieldValue('kandang_id', warehouse?.value);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', '');
}; };
const productWarehouseChangeHandler = ( const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_warehouse', val as OptionType); formik.setFieldValue('product_warehouse', val as OptionType);
const newId = (val as OptionType)?.value; const newId = (val as OptionType)?.value;
formik.setFieldValue('product_warehouse_id', newId); formik.setFieldValue('product_warehouse_id', newId);
@@ -250,13 +191,7 @@ const SalesOrderProductForm = ({
(item: ProductWarehouse) => item.id === newId (item: ProductWarehouse) => item.id === newId
); );
setSelectedProductWarehouse(productWarehouse || null); setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || ''); formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if ( if (
productWarehouse?.week !== undefined && productWarehouse?.week !== undefined &&
@@ -269,8 +204,6 @@ const SalesOrderProductForm = ({
} }
handleBlurField('qty'); handleBlurField('qty');
} else { } else {
setSelectedProductWarehouse(null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', ''); formik.setFieldValue('qty', '');
formik.setFieldValue('uom', ''); formik.setFieldValue('uom', '');
formik.setFieldValue('week', null); formik.setFieldValue('week', null);
@@ -284,12 +217,9 @@ const SalesOrderProductForm = ({
formik.resetForm({ formik.resetForm({
values: { values: {
vehicle_number: '', vehicle_number: '',
warehouse_id: undefined,
warehouse: null,
kandang_id: undefined, kandang_id: undefined,
kandang: null, kandang: null,
product_warehouse: null, product_warehouse: null,
product_warehouse_data: null,
product_warehouse_id: undefined, product_warehouse_id: undefined,
unit_price: '', unit_price: '',
total_weight: '', total_weight: '',
@@ -310,15 +240,18 @@ const SalesOrderProductForm = ({
}); });
}; };
const handleBlurField = (field: string) => { const handleBlurField = useCallback(
setCurrentInput(field); (field: string) => {
setCurrentInput(field);
handleMarketingCalculation(field, { handleMarketingCalculation(field, {
values: formik.values, values: formik.values,
setFieldValue: formik.setFieldValue, setFieldValue: formik.setFieldValue,
hasSisaBerat, hasSisaBerat,
}); });
}; },
[formik.values, formik.setFieldValue, hasSisaBerat]
);
// Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty) // Handler untuk onChange - auto calculation real-time untuk field yang mempengaruhi total_price (total_peti, weight_per_convertion, price_per_convertion, sisa_berat, price_sisa_berat, price_per_qty, qty)
const handleFieldChange = ( const handleFieldChange = (
@@ -377,12 +310,10 @@ const SalesOrderProductForm = ({
); );
useEffect(() => { useEffect(() => {
handleBlurField('week'); if (formik.values.week) {
}, [formik.values.week]); handleBlurField('week');
}
useEffect(() => { }, [formik.values.week, handleBlurField]);
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
}, [initialValues?.product_warehouse_data]);
return ( return (
<> <>
@@ -422,22 +353,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.vehicle_number} errorMessage={formik.errors.vehicle_number}
/> />
{/* Gudang Fisik */} {/* Gudang */}
<SelectInputRadio <SelectInputRadio
required required
label='Gudang Fisik' label='Gudang'
options={warehouseOptions} options={kandangSourceOptions}
isLoading={isLoadingWarehouseOptions} isLoading={isLoadingKandangSourceOptions}
value={formik.values.warehouse} value={formik.values.kandang}
onChange={warehouseChangeHandler} onChange={kandangChangeHandler}
isClearable isClearable
onInputChange={setWarehouseSearchValue} onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreWarehouses} onMenuScrollToBottom={loadMoreKandang}
isError={ isError={
formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }
errorMessage={formik.errors.warehouse_id} errorMessage={formik.errors.kandang_id}
placeholder='Pilih Gudang Fisik' placeholder='Pilih Gudang'
/> />
{/* Kategori */} {/* Kategori */}
@@ -448,9 +379,8 @@ const SalesOrderProductForm = ({
value={formik.values.marketing_type} value={formik.values.marketing_type}
onChange={(val) => { onChange={(val) => {
formik.setFieldValue('marketing_type', val); formik.setFieldValue('marketing_type', val);
productWarehouseChangeHandler(null); warehouseChangeHandler(null);
formik.setFieldValue('product_warehouse', null); formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('product_warehouse_id', null); formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('convertion_unit', null); formik.setFieldValue('convertion_unit', null);
formik.setFieldValue('weight_per_convertion', null); formik.setFieldValue('weight_per_convertion', null);
@@ -467,18 +397,18 @@ const SalesOrderProductForm = ({
options={productOptionsFiltered} options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions} isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse} value={formik.values.product_warehouse}
onChange={productWarehouseChangeHandler} onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue} onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse} onMenuScrollToBottom={loadMoreWarehouse}
isClearable isClearable
placeholder={ placeholder={
formik.values.warehouse_id formik.values.kandang_id
? productOptionsFiltered.length == 0 ? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia' ? 'Tidak ada produk yang tersedia'
: 'Pilih produk' : 'Pilih produk'
: 'Pilih Gudang Fisik Terlebih Dahulu' : 'Pilih Kandang Terlebih Dahulu'
} }
isDisabled={!formik.values.warehouse_id} isDisabled={!formik.values.kandang_id}
isError={ isError={
formik.touched.product_warehouse_id && formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id) Boolean(formik.errors.product_warehouse_id)
@@ -546,7 +476,7 @@ const SalesOrderProductForm = ({
<input <input
type='radio' type='radio'
checked={ checked={
formik.values.convertion_unit?.value.toLowerCase() === formik.values.convertion_unit?.value ===
option.value option.value
} }
onChange={() => null} onChange={() => null}
@@ -569,9 +499,7 @@ const SalesOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`} } per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''} value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value) const value = Number(e.target.value);
? Number(e.target.value)
: '';
handleFieldChange('weight_per_convertion', value, () => handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name) setCurrentInput(e.target.name)
); );
@@ -625,7 +553,7 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Peti' placeholder='Masukan Total Peti'
endAdornment={ endAdornment={
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Peti</span> <span className='text-sm text-base-content/50'>Kg</span>
</div> </div>
} }
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`} bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -675,9 +603,6 @@ const SalesOrderProductForm = ({
} }
errorMessage={formik.errors.total_weight} errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot' placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/> />
)} )}
@@ -745,34 +670,12 @@ const SalesOrderProductForm = ({
/> />
)} )}
{/* Harga Satuan per Uom Produk Warehouse */} {/* Harga per butir untuk TELUR + QTY */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan...'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' && {formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && ( formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput <NumberInput
required required
label='Harga / Kg (Rp)' label='Harga / Butir (Rp)'
name='price_per_qty' name='price_per_qty'
value={formik.values.price_per_qty ?? undefined} value={formik.values.price_per_qty ?? undefined}
onChange={(e) => { onChange={(e) => {
@@ -786,7 +689,29 @@ const SalesOrderProductForm = ({
Boolean(formik.errors.price_per_qty) Boolean(formik.errors.price_per_qty)
} }
errorMessage={formik.errors.price_per_qty} errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg' placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/> />
)} )}
@@ -5,9 +5,8 @@ import { Icon } from '@iconify/react';
import { useRef, useMemo } from 'react'; import { useRef, useMemo } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport'; import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import { Marketing } from '@/types/api/marketing/marketing'; import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing';
import { Warehouse } from '@/types/api/master-data/warehouse'; import { Warehouse } from '@/types/api/master-data/warehouse';
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
type DeliveryOrderProductTableProps = { type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[]; data: DeliveryOrderProductFormValues[];
@@ -56,17 +55,14 @@ const DeliveryOrderProductTable = ({
const deliveryItems = useMemo(() => { const deliveryItems = useMemo(() => {
if (!hasDeliveryOrder) return []; if (!hasDeliveryOrder) return [];
return ( return (
marketing?.delivery_order?.flatMap((doItem) => marketing?.delivery_order?.flatMap((doItem) =>
DeliveryProductToFieldValues(marketing?.sales_order, doItem).map( doItem.deliveries.map((delivery) => ({
(delivery) => ({ ...delivery,
...delivery, do_number: doItem.do_number,
do_number: doItem.do_number, delivery_date: doItem.delivery_date,
delivery_date: doItem.delivery_date, warehouse: doItem.warehouse,
warehouse: doItem.warehouse, }))
})
)
) ?? [] ) ?? []
); );
}, [marketing?.delivery_order, hasDeliveryOrder]); }, [marketing?.delivery_order, hasDeliveryOrder]);
@@ -85,7 +81,7 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'> <th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'> <div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div> <div>Value</div>
{/* {formType !== 'success' && {formType !== 'success' &&
(formType === 'add_delivery' || (formType === 'add_delivery' ||
formType === 'edit_delivery' || formType === 'edit_delivery' ||
formType === 'detail') && ( formType === 'detail') && (
@@ -102,16 +98,15 @@ const DeliveryOrderProductTable = ({
<Icon icon='heroicons:pencil' width={20} height={20} /> <Icon icon='heroicons:pencil' width={20} height={20} />
</Button> </Button>
</div> </div>
)} */} )}
</div> </div>
</th> </th>
</tr> </tr>
<> <>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td> <td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{doItem?.warehouse?.name || {doItem?.warehouse?.name ||
item.marketing_product?.warehouse?.label ||
item.marketing_product?.product_warehouse_data?.warehouse?.name} item.marketing_product?.product_warehouse_data?.warehouse?.name}
</td> </td>
</tr> </tr>
@@ -124,7 +119,7 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Qty</td> <td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.qty !== undefined && item.qty !== null && item.qty !== '' {item.qty
? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}` ? `${formatNumber(parseFloat(item.qty as string))} ${item.marketing_product?.uom ?? ''}`
: '-'} : '-'}
</td> </td>
@@ -141,15 +136,12 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Bobot</td> <td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))} Kg {formatNumber(Number(item.total_weight))}
</td> </td>
</tr> </tr>
)} )}
<tr> <tr>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>Total Harga Satuan</td>
Total Harga Satuan
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))} {formatCurrency(parseFloat(item.unit_price as string))}
</td> </td>
@@ -219,7 +211,7 @@ const DeliveryOrderProductTable = ({
}; };
const renderDeliveryOrderContent = ( const renderDeliveryOrderContent = (
item: DeliveryOrderProductFormValues & { item: BaseDelivery & {
do_number: string; do_number: string;
delivery_date: string; delivery_date: string;
warehouse: Warehouse; warehouse: Warehouse;
@@ -238,43 +230,25 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'> <th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'> <div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div> <div>Value</div>
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
</div> </div>
</th> </th>
</tr> </tr>
<> <>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td> <td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td> <td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Produk</td> <td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse_data?.product.name} {item.product_warehouse?.product?.name}
</td> </td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Qty</td> <td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{item.qty !== undefined && item.qty !== null && item.qty !== '' {item.qty
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}` ? `${formatNumber(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}`
: '-'} : '-'}
</td> </td>
</tr> </tr>
@@ -297,13 +271,13 @@ const DeliveryOrderProductTable = ({
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td> <td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.unit_price))} {formatCurrency(item.unit_price)}
</td> </td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td> <td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.total_price))} {formatCurrency(item.total_price)}
</td> </td>
</tr> </tr>
</> </>
@@ -359,9 +333,7 @@ const DeliveryOrderProductTable = ({
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'> <div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{hasDeliveryOrder {hasDeliveryOrder
? deliveryItems.map((item, index) => ( ? deliveryItems.map((item, index) => (
<div <div key={`do-table-${item.product_warehouse?.id}-${index}`}>
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
>
{formType === 'success' ? ( {formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'> <div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table <table
@@ -377,11 +349,8 @@ const DeliveryOrderProductTable = ({
</div> </div>
) : ( ) : (
<Card <Card
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`} key={`do-table-${item.product_warehouse?.id}-${index}`}
title={ title={item.product_warehouse?.product?.name || 'Produk'}
item.marketing_product?.product_warehouse_data?.product
.name || 'Produk'
}
collapsible={true} collapsible={true}
defaultCollapsed={false} defaultCollapsed={false}
variant='bordered' variant='bordered'
@@ -73,10 +73,8 @@ const SalesOrderProductTable = ({
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td> <td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td> <td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
{item.warehouse?.label ?? item.kandang?.label}
</td>
</tr> </tr>
<tr> <tr>
<td className='text-sm px-4 py-3'>Kategori</td> <td className='text-sm px-4 py-3'>Kategori</td>
@@ -137,22 +135,8 @@ const SalesOrderProductTable = ({
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`} {`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td> </td>
</tr> </tr>
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(
parseFloat(item.unit_price as string) *
parseFloat(String(item.weight_per_convertion))
)}
</td>
</tr>
)}
<tr> <tr>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>Harga Satuan</td>
Harga Satuan
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'> <td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))} {formatCurrency(parseFloat(item.unit_price as string))}
</td> </td>
@@ -6,7 +6,6 @@ import { useMemo, useState } from 'react';
import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper'; import { formatDate, formatNumber, formatVechicleNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
interface DeliveryOrderExportProps { interface DeliveryOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -20,9 +19,6 @@ const DeliveryOrderExport = ({
}: DeliveryOrderExportProps) => { }: DeliveryOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data; const salesData = data;
const searchParams = useSearchParams();
const action = searchParams.get('action');
const id = searchParams.get('id');
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
@@ -53,7 +49,6 @@ const DeliveryOrderExport = ({
toast.error('Failed to generate PDF. Please try again.'); toast.error('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
window.location.href = `/marketing?action=${action}&id=${id}`;
} }
}; };
@@ -92,7 +87,7 @@ const PDFDocument = ({
return ( return (
deliveryOrder.deliveries?.reduce((a, b) => a + b.total_price, 0) ?? 0 deliveryOrder.deliveries?.reduce((a, b) => a + b.total_price, 0) ?? 0
); );
}, []); }, [deliveryOrder.deliveries]);
return ( return (
<Document> <Document>
@@ -101,8 +96,8 @@ const PDFDocument = ({
<View style={pdfStyles.header}> <View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text> <Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}> <Text style={pdfStyles.address}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Bandung Barat, Jawa Barat 40514 Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text> </Text>
<View style={pdfStyles.divider} /> <View style={pdfStyles.divider} />
</View> </View>
@@ -6,7 +6,6 @@ import { useMemo, useState } from 'react';
import { formatDate, formatNumber } from '@/lib/helper'; import { formatDate, formatNumber } from '@/lib/helper';
import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles'; import pdfStyles from '@/components/pages/marketing/pdf/styles/MarketingPDFStyles';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
interface SalesOrderExportProps { interface SalesOrderExportProps {
data?: Marketing; data?: Marketing;
@@ -16,9 +15,6 @@ interface SalesOrderExportProps {
const SalesOrderExport = ({ data }: SalesOrderExportProps) => { const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
const salesData = data; const salesData = data;
const searchParams = useSearchParams();
const action = searchParams.get('action');
const id = searchParams.get('id');
const handleDownloadPDF = async () => { const handleDownloadPDF = async () => {
if (!salesData) { if (!salesData) {
@@ -47,7 +43,6 @@ const SalesOrderExport = ({ data }: SalesOrderExportProps) => {
toast.error('Failed to generate PDF. Please try again.'); toast.error('Failed to generate PDF. Please try again.');
} finally { } finally {
setIsGeneratingPDF(false); setIsGeneratingPDF(false);
window.location.href = `/marketing?action=${action}&id=${id}`;
} }
}; };
@@ -87,8 +82,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
<View style={pdfStyles.header}> <View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text> <Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}> <Text style={pdfStyles.address}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Bandung Barat, Jawa Barat 40514 Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text> </Text>
<View style={pdfStyles.divider} /> <View style={pdfStyles.divider} />
</View> </View>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +20,8 @@ import { Area } from '@/types/api/master-data/area';
import { AreaApi } from '@/services/api/master-data'; import { AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const AreasTable = () => { const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,14 +114,12 @@ const AreasTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'areas-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -134,8 +137,17 @@ const AreasTable = () => {
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined); const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +20,8 @@ import { Bank } from '@/types/api/master-data/bank';
import { BankApi } from '@/services/api/master-data'; import { BankApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const BanksTable = () => { const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,14 +114,12 @@ const BanksTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'banks-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -134,8 +137,17 @@ const BanksTable = () => {
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined); const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +20,8 @@ import { Customer } from '@/types/api/master-data/customer';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const CustomersTable = () => { const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,14 +114,12 @@ const CustomersTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'customers-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -136,8 +139,17 @@ const CustomersTable = () => {
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -189,11 +201,6 @@ const CustomersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props: CellContext<Customer, unknown>) => { cell: (props: CellContext<Customer, unknown>) => {
@@ -27,9 +27,6 @@ export const CustomerFormSchema = Yup.object({
.email('Format email tidak valid!') .email('Format email tidak valid!')
.required('Email wajib diisi!'), .required('Email wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -142,7 +142,6 @@ const CustomerForm = ({
}, },
type: normalizeType(initialValues?.type), type: normalizeType(initialValues?.type),
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
}; };
}, [initialValues]); }, [initialValues]);
@@ -165,7 +164,6 @@ const CustomerForm = ({
pic_id: values.picId, pic_id: values.picId,
type: (values.type as OptionType).value as string, type: (values.type as OptionType).value as string,
address: values.address, address: values.address,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
}; };
@@ -288,22 +286,6 @@ const CustomerForm = ({
errorMessage={formik.errors.phone} errorMessage={formik.errors.phone}
readOnly={formType === 'detail'} readOnly={formType === 'detail'}
/> />
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank customer'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +20,8 @@ import { Flock } from '@/types/api/master-data/flock';
import { FlockApi } from '@/services/api/master-data'; import { FlockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const FlockTable = () => { const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,14 +114,12 @@ const FlockTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'flock-table',
}); });
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
@@ -136,8 +139,17 @@ const FlockTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -28,6 +35,7 @@ import { User } from '@/types/api/api-general';
import { formatNumber } from '@/lib/helper'; import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
KandangFilterSchema, KandangFilterSchema,
KandangFilterType, KandangFilterType,
@@ -114,21 +122,20 @@ const RowOptionsMenu = ({
}; };
const KandangsTable = () => { const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
locationFilter: undefined, locationFilter: '',
picFilter: undefined, picFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -136,8 +143,6 @@ const KandangsTable = () => {
locationFilter: 'location_id', locationFilter: 'location_id',
picFilter: 'pic_id', picFilter: 'pic_id',
}, },
persist: true,
storeName: 'kandangs-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -146,34 +151,22 @@ const KandangsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({ const formik = useFormik<KandangFilterType>({
initialValues: { initialValues: {
location: tableFilterState.locationFilter, location_id: null,
pic: tableFilterState.picFilter, pic_id: null,
}, },
validationSchema: KandangFilterSchema, validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location || undefined, true); updateFilter('locationFilter', values.location_id || '');
updateFilter('picFilter', values.pic || undefined, true); updateFilter('picFilter', values.pic_id || '');
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); 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 ===== // ===== LOCATION OPTIONS =====
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
@@ -201,15 +194,43 @@ const KandangsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterLocationChange = ( const handleFilterLocationChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const location = val as OptionType | null;
setFieldValue('location', val); const locationId = location?.value ? String(location.value) : null;
};
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => { formik.setFieldValue('location_id', locationId);
setFieldValue('pic', val); },
}; [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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -234,8 +255,17 @@ const KandangsTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -284,10 +314,6 @@ const KandangsTable = () => {
accessorFn: (row) => row.pic?.name ?? '-', accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC', header: 'PIC',
}, },
{
accessorFn: (row) => row.kandang_group?.name ?? '-',
header: 'Kandang Group',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => { cell: (props: CellContext<Kandang, unknown>) => {
@@ -445,13 +471,13 @@ const KandangsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={formik.values.location} value={locationIdValue}
onChange={handleFilterLocationChange} onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
@@ -464,7 +490,7 @@ const KandangsTable = () => {
label='PIC' label='PIC'
placeholder='Pilih PIC' placeholder='Pilih PIC'
options={picOptions} options={picOptions}
value={formik.values.pic} value={picIdValue}
onChange={handleFilterPicChange} onChange={handleFilterPicChange}
onInputChange={setPicInputValue} onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions} isLoading={isLoadingPicOptions}
@@ -480,14 +506,17 @@ const KandangsTable = () => {
type='button' type='button'
variant='soft' 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' 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={formikResetHandler} onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid} disabled={!formik.isValid || formik.isSubmitting}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -1,19 +1,11 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const KandangFilterSchema = Yup.object().shape({ export const KandangFilterSchema = object().shape({
location: Yup.object({ location_id: string().nullable(),
value: Yup.string().nullable(), pic_id: string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
pic: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type KandangFilterType = { export type KandangFilterType = {
location?: OptionType<string>; location_id: string | null;
pic?: OptionType<string>; pic_id: string | null;
}; };
@@ -1,4 +1,3 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup'; import * as Yup from 'yup';
type KandangFormSchemaType = { type KandangFormSchemaType = {
@@ -20,7 +19,6 @@ type KandangFormSchemaType = {
} }
| undefined | undefined
| null; | null;
group?: OptionType;
}; };
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> = export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
@@ -44,11 +42,6 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
}).nullable(), }).nullable(),
group: Yup.object({
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
label: Yup.string().required('Kandang Grup wajib diisi!'),
}).required('Kandang Grup wajib diisi!'),
}); });
export const UpdateKandangFormSchema = KandangFormSchema; export const UpdateKandangFormSchema = KandangFormSchema;
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { getIn, useFormik } from 'formik'; import { useFormik } from 'formik';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
@@ -34,8 +34,6 @@ import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList'; import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors'; import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general'; import { User } from '@/types/api/api-general';
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
interface KandangFormProps { interface KandangFormProps {
type?: 'add' | 'edit' | 'detail'; type?: 'add' | 'edit' | 'detail';
@@ -98,12 +96,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.pic.name, label: initialValues.pic.name,
} }
: null, : null,
group: initialValues?.kandang_group
? {
value: initialValues.kandang_group.id,
label: initialValues.kandang_group.name,
}
: undefined,
}; };
}, [initialValues]); }, [initialValues]);
@@ -119,7 +111,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
location_id: values.locationId!, location_id: values.locationId!,
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0, capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId!, pic_id: values.picId!,
group_id: values.group?.value as number,
}; };
switch (type) { switch (type) {
@@ -171,23 +162,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
formik.setFieldValue('picId', (val as OptionType)?.value); formik.setFieldValue('picId', (val as OptionType)?.value);
}; };
// Kandang Group
const {
setInputValue: setKandangGroupSelectInputValue,
options: kandangGroupOptions,
isLoadingOptions: isLoadingKandangGroupOptions,
loadMore: loadMoreKandangGroups,
} = useSelect<DailyChecklistKandang>(
DailyChecklistKandangApi.basePath,
'id',
'name'
);
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('group', true);
formik.setFieldValue('group', val);
};
const deleteKandangClickHandler = () => { const deleteKandangClickHandler = () => {
deleteModal.openModal(); deleteModal.openModal();
}; };
@@ -295,24 +269,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
isDisabled={type === 'detail'} isDisabled={type === 'detail'}
isClearable isClearable
/> />
<SelectInput
required
label='Kandang Group'
value={formik.values.group ?? undefined}
onChange={kandangGroupChangeHandler}
options={kandangGroupOptions}
onInputChange={setKandangGroupSelectInputValue}
onMenuScrollToBottom={loadMoreKandangGroups}
isLoading={isLoadingKandangGroupOptions}
isError={formik.touched.group && Boolean(formik.errors.group)}
errorMessage={
getIn(formik.errors.group, 'value') ??
(formik.errors.group as string)
}
isDisabled={type === 'detail'}
isClearable
/>
</div> </div>
<div className='flex flex-row justify-between gap-2 flex-wrap'> <div className='flex flex-row justify-between gap-2 flex-wrap'>
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -25,6 +32,7 @@ import { Area } from '@/types/api/master-data/area';
import { LocationApi, AreaApi } from '@/services/api/master-data'; import { LocationApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
LocationFilterSchema, LocationFilterSchema,
LocationFilterType, LocationFilterType,
@@ -110,27 +118,25 @@ const RowOptionsMenu = ({
}; };
const LocationsTable = () => { const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
areaFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
areaFilter: undefined, areaFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
areaFilter: 'area_id', areaFilter: 'area_id',
}, },
persist: true,
storeName: 'locations-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -139,28 +145,19 @@ const LocationsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({ const formik = useFormik<LocationFilterType>({
initialValues: { initialValues: {
area: tableFilterState.areaFilter, area_id: null,
}, },
validationSchema: LocationFilterSchema, validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area || undefined, true); updateFilter('areaFilter', values.area_id || '');
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('areaFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('areaFilter', undefined, true);
formik.resetForm({
values: {
area: undefined,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS ===== // ===== AREA OPTIONS =====
const { const {
setInputValue: setAreaInputValue, setInputValue: setAreaInputValue,
@@ -175,9 +172,24 @@ const LocationsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => { const handleFilterAreaChange = useCallback(
formik.setFieldValue('area', val); (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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -200,10 +212,19 @@ const LocationsTable = () => {
>(undefined); >(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('locations-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -404,13 +425,13 @@ const LocationsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Area' label='Area'
placeholder='Pilih Area' placeholder='Pilih Area'
options={areaOptions} options={areaOptions}
value={formik.values.area} value={areaIdValue}
onChange={handleFilterAreaChange} onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions} isLoading={isLoadingAreaOptions}
@@ -426,7 +447,10 @@ const LocationsTable = () => {
type='button' type='button'
variant='soft' 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' 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={formikResetHandler} onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
@@ -1,13 +1,9 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const LocationFilterSchema = Yup.object().shape({ export const LocationFilterSchema = object().shape({
area: Yup.object({ area_id: string().nullable(),
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type LocationFilterType = { export type LocationFilterType = {
area?: OptionType<string>; area_id: string | null;
}; };
@@ -20,6 +20,8 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
import { NonstockApi } from '@/services/api/master-data'; import { NonstockApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const NonstocksTable = () => { const NonstocksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,16 +114,22 @@ const NonstocksTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -137,7 +148,8 @@ const NonstocksTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +21,7 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const ProductCategoryTable = () => { const ProductCategoryTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -115,10 +120,12 @@ const ProductCategoryTable = () => {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
}, },
persist: true,
storeName: 'product-category-table',
}); });
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -137,7 +144,8 @@ const ProductCategoryTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -206,6 +214,10 @@ const ProductCategoryTable = () => {
[tableFilterState.pageSize, tableFilterState.page, deleteModal] [tableFilterState.pageSize, tableFilterState.page, deleteModal]
); );
useEffect(() => {
setTableState('product-category-table', pathname);
}, [pathname, setTableState]);
return ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -26,6 +33,7 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
import { formatCurrency } from '@/lib/helper'; import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
ProductFilterSchema, ProductFilterSchema,
ProductFilterType, ProductFilterType,
@@ -111,27 +119,25 @@ const RowOptionsMenu = ({
}; };
const ProductsTable = () => { const ProductsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
productCategoryFilter?: OptionType<string>;
}>({
initial: { initial: {
search: '', search: '',
productCategoryFilter: undefined, productCategoryFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
productCategoryFilter: 'product_category_id', productCategoryFilter: 'product_category_id',
}, },
persist: true,
storeName: 'product-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -140,32 +146,19 @@ const ProductsTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<ProductFilterType>({ const formik = useFormik<ProductFilterType>({
initialValues: { initialValues: {
product_category: tableFilterState.productCategoryFilter, product_category_id: null,
}, },
validationSchema: ProductFilterSchema, validationSchema: ProductFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter( updateFilter('productCategoryFilter', values.product_category_id || '');
'productCategoryFilter',
values.product_category || undefined,
true
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('productCategoryFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('productCategoryFilter', undefined, true);
formik.resetForm({
values: {
product_category: undefined,
},
});
filterModal.closeModal();
};
// ===== PRODUCT CATEGORY OPTIONS ===== // ===== PRODUCT CATEGORY OPTIONS =====
const { const {
setInputValue: setProductCategoryInputValue, setInputValue: setProductCategoryInputValue,
@@ -180,11 +173,25 @@ const ProductsTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterProductCategoryChange = ( const handleFilterProductCategoryChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const category = val as OptionType | null;
formik.setFieldValue('product_category', val); 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]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -192,6 +199,10 @@ const ProductsTable = () => {
formik.validateForm(); formik.validateForm();
}; };
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -209,8 +220,13 @@ const ProductsTable = () => {
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setTableState('product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -461,13 +477,13 @@ const ProductsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Kategori Produk' label='Kategori Produk'
placeholder='Pilih Kategori Produk' placeholder='Pilih Kategori Produk'
options={productCategoryOptions} options={productCategoryOptions}
value={formik.values.product_category} value={productCategoryIdValue}
onChange={handleFilterProductCategoryChange} onChange={handleFilterProductCategoryChange}
onInputChange={setProductCategoryInputValue} onInputChange={setProductCategoryInputValue}
isLoading={isLoadingProductCategoryOptions} isLoading={isLoadingProductCategoryOptions}
@@ -483,7 +499,10 @@ const ProductsTable = () => {
type='button' type='button'
variant='soft' 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' 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={formikResetHandler} onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
@@ -1,13 +1,9 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const ProductFilterSchema = Yup.object().shape({ export const ProductFilterSchema = object().shape({
product_category: Yup.object({ product_category_id: string().nullable(),
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}); });
export type ProductFilterType = { export type ProductFilterType = {
product_category?: OptionType<string>; product_category_id: string | null;
}; };
@@ -154,17 +154,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku, sku: values.sku,
uom_id: values.uom_id, uom_id: values.uom_id,
product_category_id: values.product_category_id, product_category_id: values.product_category_id,
product_price: parseFloat(values.product_price.toString()) || 0, product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price selling_price: values.selling_price
? parseFloat(values.selling_price.toString()) || 0 ? parseInt(values.selling_price.toString()) || 0
: undefined, : undefined,
tax: values.tax ? parseFloat(values.tax.toString()) || 0 : undefined, tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period expiry_period: values.expiry_period
? parseFloat(values.expiry_period.toString()) || 0 ? parseInt(values.expiry_period.toString()) || 0
: undefined, : undefined,
suppliers: values.suppliers.map((s) => ({ suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number, supplier_id: s.supplier?.value as number,
price: parseFloat(s.price.toString()) || 0, price: parseInt(s.price.toString()) || 0,
})), })),
flag: values.flag, flag: values.flag,
sub_flags: values.sub_flags, sub_flags: values.sub_flags,
@@ -128,44 +128,27 @@ const ProductionStandardTable = () => {
pageSize: 'limit', pageSize: 'limit',
projectCategoryFilter: 'project_category', projectCategoryFilter: 'project_category',
}, },
persist: true,
storeName: 'production-standard-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
const filterModal = useModal(); const filterModal = useModal();
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
const filterInitialValues = useMemo<ProductionStandardFilterType>(
() => ({
project_category: tableFilterState.projectCategoryFilter || null,
}),
[tableFilterState.projectCategoryFilter]
);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<ProductionStandardFilterType>({ const formik = useFormik<ProductionStandardFilterType>({
initialValues: filterInitialValues, initialValues: {
project_category: null,
},
validationSchema: ProductionStandardFilterSchema, validationSchema: ProductionStandardFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('projectCategoryFilter', values.project_category || ''); updateFilter('projectCategoryFilter', values.project_category || '');
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('projectCategoryFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('projectCategoryFilter', '', true);
formik.resetForm({
values: {
project_category: null,
},
});
filterModal.closeModal();
};
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) ===== // ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
const projectCategoryOptions = useMemo( const projectCategoryOptions = useMemo(
() => [ () => [
@@ -398,7 +381,7 @@ const ProductionStandardTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio <SelectInputRadio
label='Kategori' label='Kategori'
@@ -414,9 +397,13 @@ const ProductionStandardTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset' type='button'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -7,6 +7,7 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -29,7 +30,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
SupplierFilterSchema, SupplierFilterSchema,
SupplierFilterType, SupplierFilterType,
@@ -116,21 +117,20 @@ const RowOptionsMenu = ({
}; };
const SuppliersTable = () => { const SuppliersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<{ } = useTableFilter({
search: string;
categoryFilter?: OptionType<string>;
flagFilter?: string;
}>({
initial: { initial: {
search: '', search: '',
categoryFilter: undefined, categoryFilter: '',
flagFilter: undefined, flagFilter: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -138,8 +138,6 @@ const SuppliersTable = () => {
categoryFilter: 'category_id', categoryFilter: 'category_id',
flagFilter: 'flag', flagFilter: 'flag',
}, },
persist: true,
storeName: 'supplier-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -148,33 +146,26 @@ const SuppliersTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<SupplierFilterType>({ const formik = useFormik<SupplierFilterType>({
initialValues: { initialValues: {
category: tableFilterState.categoryFilter, category_id: null,
flag: tableFilterState.flagFilter === 'EKSPEDISI', flag: false,
}, },
validationSchema: SupplierFilterSchema, validationSchema: SupplierFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category || undefined, true); updateFilter('categoryFilter', values.category_id || '');
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true); updateFilter(
'flagFilter',
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
);
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); 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; const { setFieldValue } = formik;
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) ===== // ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
@@ -196,11 +187,15 @@ const SuppliersTable = () => {
); );
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterCategoryChange = ( const handleFilterCategoryChange = useCallback(
val: OptionType | OptionType[] | null (val: OptionType | OptionType[] | null) => {
) => { const option = val as OptionType | null;
setFieldValue('category', val); const categoryId = option?.value ? String(option.value) : null;
};
setFieldValue('category_id', categoryId);
},
[setFieldValue]
);
const handleFilterFlagChange = useCallback( const handleFilterFlagChange = useCallback(
(val: OptionType | OptionType[] | null) => { (val: OptionType | OptionType[] | null) => {
@@ -218,13 +213,13 @@ const SuppliersTable = () => {
); );
// ===== FILTER HELPERS ===== // ===== FILTER HELPERS =====
// const categoryIdValue = useMemo(() => { const categoryIdValue = useMemo(() => {
// if (!formik.values.category_id) return null; if (!formik.values.category_id) return null;
// return ( return (
// categoryOptions.find((opt) => opt.value === formik.values.category_id) || categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
// null null
// ); );
// }, [formik.values.category_id, categoryOptions]); }, [formik.values.category_id, categoryOptions]);
const flagValue = useMemo(() => { const flagValue = useMemo(() => {
if (formik.values.flag === null) return null; if (formik.values.flag === null) return null;
@@ -248,6 +243,14 @@ const SuppliersTable = () => {
} }
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]); }, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('suppliers-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -266,7 +269,8 @@ const SuppliersTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -326,11 +330,6 @@ const SuppliersTable = () => {
accessorKey: 'email', accessorKey: 'email',
header: 'Email', header: 'Email',
}, },
{
accessorKey: 'bank_name',
header: 'Nama Bank',
cell: (props) => props.row.original.bank_name || '-',
},
{ {
accessorKey: 'address', accessorKey: 'address',
header: 'Alamat', header: 'Alamat',
@@ -492,13 +491,13 @@ const SuppliersTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio <SelectInputRadio
label='Kategori' label='Kategori'
placeholder='Pilih Kategori' placeholder='Pilih Kategori'
options={categoryOptions} options={categoryOptions}
value={formik.values.category} value={categoryIdValue}
onChange={handleFilterCategoryChange} onChange={handleFilterCategoryChange}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
@@ -518,9 +517,13 @@ const SuppliersTable = () => {
{/* Modal Footer */} {/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'> <div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button <Button
type='reset' type='button'
variant='soft' 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' 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 Reset Filter
</Button> </Button>
@@ -1,16 +1,11 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, boolean, object } from 'yup';
import * as Yup from 'yup';
export const SupplierFilterSchema = Yup.object().shape({ export const SupplierFilterSchema = object().shape({
category: Yup.object({ category_id: string().nullable(),
value: Yup.string().required(), flag: boolean().nullable(),
label: Yup.string().required(),
}).nullable(),
flag: Yup.boolean().nullable(),
}); });
export type SupplierFilterType = { export type SupplierFilterType = {
category?: OptionType<string>; category_id: string | null;
flag: boolean | null; flag: boolean | null;
}; };
@@ -31,9 +31,6 @@ export const SupplierFormSchema = Yup.object({
npwp: Yup.string() npwp: Yup.string()
.matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor NPWP hanya boleh berisi angka!')
.required('Nomor NPWP wajib diisi!'), .required('Nomor NPWP wajib diisi!'),
bank_name: Yup.string()
.min(3, 'Nama bank minimal 3 karakter!')
.required('Nama bank wajib diisi!'),
account_number: Yup.string() account_number: Yup.string()
.matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!') .matches(/^[0-9]+$/, 'Nomor rekening hanya boleh berisi angka!')
.required('Nomor rekening wajib diisi!'), .required('Nomor rekening wajib diisi!'),
@@ -122,7 +122,6 @@ const SupplierForm = ({
email: initialValues?.email ?? '', email: initialValues?.email ?? '',
address: initialValues?.address ?? '', address: initialValues?.address ?? '',
npwp: initialValues?.npwp ?? '', npwp: initialValues?.npwp ?? '',
bank_name: initialValues?.bank_name ?? '',
account_number: initialValues?.account_number ?? '', account_number: initialValues?.account_number ?? '',
due_date: initialValues?.due_date ?? 1, due_date: initialValues?.due_date ?? 1,
}; };
@@ -150,7 +149,6 @@ const SupplierForm = ({
email: values.email, email: values.email,
address: values.address, address: values.address,
npwp: values.npwp, npwp: values.npwp,
bank_name: values.bank_name,
account_number: values.account_number, account_number: values.account_number,
due_date: parseInt(values.due_date.toString()), due_date: parseInt(values.due_date.toString()),
}; };
@@ -370,22 +368,6 @@ const SupplierForm = ({
errorMessage={formik.errors.npwp} errorMessage={formik.errors.npwp}
readOnly={formType === 'detail'} readOnly={formType === 'detail'}
/> />
<TextInput
required
label='Nama Bank'
name='bank_name'
placeholder='Masukkan nama bank supplier'
value={formik.values.bank_name}
onChange={(e) =>
formik.setFieldValue('bank_name', e.target.value.toUpperCase())
}
onBlur={formik.handleBlur}
isError={
formik.touched.bank_name && Boolean(formik.errors.bank_name)
}
errorMessage={formik.errors.bank_name}
readOnly={formType === 'detail'}
/>
<TextInput <TextInput
required required
label='Nomor Rekening' label='Nomor Rekening'
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -20,6 +20,8 @@ import { Uom } from '@/types/api/master-data/uom';
import { UomApi } from '@/services/api/master-data'; import { UomApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
}; };
const UomsTable = () => { const UomsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -109,16 +114,22 @@ const UomsTable = () => {
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter({ } = useTableFilter({
initial: { initial: {
search: '', search: searchValue,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -135,7 +146,8 @@ const UomsTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,13 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr'; import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -24,6 +31,7 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
import { WarehouseApi, AreaApi } from '@/services/api/master-data'; import { WarehouseApi, AreaApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { import {
WarehouseFilterSchema, WarehouseFilterSchema,
WarehouseFilterType, WarehouseFilterType,
@@ -112,6 +120,9 @@ const RowOptionsMenu = ({
}; };
const WarehousesTable = () => { const WarehousesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
@@ -130,8 +141,6 @@ const WarehousesTable = () => {
areaFilter: 'area_id', areaFilter: 'area_id',
activeProjectFlockFilter: 'active_project_flock', activeProjectFlockFilter: 'active_project_flock',
}, },
persist: true,
storeName: 'warehouses-table',
}); });
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
@@ -140,36 +149,27 @@ const WarehousesTable = () => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<WarehouseFilterType>({ const formik = useFormik<WarehouseFilterType>({
initialValues: { initialValues: {
area_id: tableFilterState.areaFilter || null, area_id: null,
active_project_flock: active_project_flock: false,
tableFilterState.activeProjectFlockFilter === 'true',
}, },
validationSchema: WarehouseFilterSchema, validationSchema: WarehouseFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '', true); updateFilter('areaFilter', values.area_id || '');
updateFilter( updateFilter(
'activeProjectFlockFilter', 'activeProjectFlockFilter',
values.active_project_flock === true ? 'true' : '', values.active_project_flock === true ? 'true' : ''
true
); );
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('activeProjectFlockFilter', '');
formik.setFieldValue('active_project_flock', false);
},
}); });
const formikResetHandler = () => { const { setFieldValue } = formik;
updateFilter('areaFilter', '', true);
updateFilter('activeProjectFlockFilter', '', true);
formik.resetForm({
values: {
area_id: null,
active_project_flock: false,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS ===== // ===== AREA OPTIONS =====
const { const {
@@ -243,6 +243,26 @@ const WarehousesTable = () => {
formik.validateForm(); 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 [sorting, setSorting] = useState<SortingState>([]);
const { const {
@@ -261,7 +281,8 @@ const WarehousesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -486,7 +507,7 @@ const WarehousesTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}> <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<SelectInput <SelectInput
label='Area' label='Area'
@@ -517,7 +538,10 @@ const WarehousesTable = () => {
type='button' type='button'
variant='soft' 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' 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={formikResetHandler} onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
> >
Reset Filter Reset Filter
</Button> </Button>
@@ -44,7 +44,9 @@ const ChickinFormKandang = ({
const afterSubmitFormChickin = () => { const afterSubmitFormChickin = () => {
setOpenChickin(true); setOpenChickin(true);
afterSubmit && afterSubmit(); if (afterSubmit) {
afterSubmit();
}
refreshApprovals(); refreshApprovals();
}; };
@@ -23,7 +23,7 @@ const ChickinLogsView = ({
rawDataApprovals: BaseApproval[]; rawDataApprovals: BaseApproval[];
}) => { }) => {
const [chickinErrorMessage, setChickinErrorMessage] = useState(''); const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore(); const { openChickinApproveModal } = useChickinStore();
const handleClickApprove = () => { const handleClickApprove = () => {
openChickinApproveModal(initialValues, async (notes?: string) => { openChickinApproveModal(initialValues, async (notes?: string) => {
@@ -40,21 +40,8 @@ const ChickinLogsView = ({
toast.error(approveChickinRes?.message as string); toast.error(approveChickinRes?.message as string);
setChickinErrorMessage(approveChickinRes?.message as string); setChickinErrorMessage(approveChickinRes?.message as string);
} }
afterSubmit && afterSubmit(); if (afterSubmit) {
}); afterSubmit();
};
const handleDeleteChickin = (chickinId: number) => {
openChickinDeleteModal(chickinId, async () => {
const deleteRes = await ChickinApi.delete(chickinId);
if (isResponseSuccess(deleteRes)) {
toast.success(deleteRes?.message || 'Chickin berhasil dihapus');
afterSubmit && afterSubmit();
}
if (isResponseError(deleteRes)) {
toast.error(deleteRes?.message || 'Gagal menghapus chickin');
} }
}); });
}; };
@@ -101,30 +88,14 @@ const ChickinLogsView = ({
<div className='text-lg font-semibold'> <div className='text-lg font-semibold'>
Chick In #{index + 1} - {latestApproval?.step_number} Chick In #{index + 1} - {latestApproval?.step_number}
</div> </div>
<div className='flex flex-row gap-2 items-center'> <PillBadge
<PillBadge content={
content={ isApproved ? 'Disetujui' : isPending ? 'Pending' : '-'
isApproved ? 'Disetujui' : isPending ? 'Pending' : '-' }
} color={
color={ isApproved ? 'green' : isPending ? 'yellow' : 'gray'
isApproved ? 'green' : isPending ? 'yellow' : 'gray' }
} />
/>
{isApproved && (
<Button
color='error'
className='w-fit text-sm text-base-100 rounded-lg shadow-sm btn-xs!'
onClick={() => handleDeleteChickin(chickin.id)}
>
<Icon
icon='heroicons:trash-solid'
width={10}
height={10}
/>
</Button>
)}
</div>
</div> </div>
{/* Tanggal Chick In */} {/* Tanggal Chick In */}
@@ -11,6 +11,7 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Dropdown from '@/components/Dropdown';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data'; import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
@@ -22,6 +23,7 @@ import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -43,7 +45,6 @@ import {
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import NumberInput from '@/components/input/NumberInput';
const RowOptionsMenu = ({ const RowOptionsMenu = ({
props, props,
@@ -58,7 +59,8 @@ const RowOptionsMenu = ({
detailClickHandler: (id: number) => void; detailClickHandler: (id: number) => void;
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = props.row.original.approval?.step_number !== 2; // TODO: change this to real condition
const showEditButton = true;
const showDeleteButton = showEditButton; const showDeleteButton = showEditButton;
@@ -147,6 +149,7 @@ const RowOptionsMenu = ({
}; };
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname(); const pathname = usePathname();
const isSuccess = useProjectFlockStore((s) => s.isSuccess); const isSuccess = useProjectFlockStore((s) => s.isSuccess);
@@ -172,9 +175,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
kandang_id: '', kandang_id: '',
category: '', category: '',
period: '', period: '',
area_name: '',
location_name: '',
kandang_name: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -186,11 +186,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
category: 'category', category: 'category',
period: 'period', period: 'period',
}, },
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
persist: true,
storeName: 'project-flock-table',
}); });
const router = useRouter(); const router = useRouter();
// ===== State ===== // ===== State =====
@@ -204,25 +200,20 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const confirmModal = useModal(); const confirmModal = useModal();
const successModal = useModal(); const successModal = useModal();
const chickinApproveModal = useModal(); const chickinApproveModal = useModal();
const chickinDeleteModal = useModal();
const closingModal = useModal(); const closingModal = useModal();
const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>( const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>(
'APPROVED' 'APPROVED'
); );
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false); const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const { const {
isChickinApproveModalOpen, isChickinApproveModalOpen,
isChickinApproveLoading, isChickinApproveLoading,
chickinApproveCallback, chickinApproveCallback,
closeChickinApproveModal, closeChickinApproveModal,
setChickinApproveLoading, setChickinApproveLoading,
isChickinDeleteModalOpen,
isChickinDeleteLoading,
chickinDeleteCallback,
closeChickinDeleteModal,
setChickinDeleteLoading,
} = useChickinStore(); } = useChickinStore();
const { const {
@@ -261,18 +252,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', values.kandang_id || ''); updateFilter('kandang_id', values.kandang_id || '');
updateFilter('category', values.category || ''); updateFilter('category', values.category || '');
updateFilter('period', values.period || ''); 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(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
@@ -282,9 +261,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', ''); updateFilter('kandang_id', '');
updateFilter('category', ''); updateFilter('category', '');
updateFilter('period', ''); updateFilter('period', '');
updateFilter('area_name', '');
updateFilter('location_name', '');
updateFilter('kandang_name', '');
setFilterAreaId(undefined); setFilterAreaId(undefined);
setFilterLocationId(undefined); setFilterLocationId(undefined);
filterModal.closeModal(); filterModal.closeModal();
@@ -326,55 +302,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[] []
); );
const periodOptions = useMemo(
() => [
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
],
[]
);
// ===== FILTER HELPERS ===== // ===== FILTER HELPERS =====
const areaValue = useMemo(() => { const areaValue = useMemo(() => {
if (!formik.values.area_id) return null; if (!formik.values.area_id) return null;
const found = areaOptions.find( return (
(opt) => String(opt.value) === formik.values.area_id areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
); );
if (found) return found; }, [formik.values.area_id, areaOptions]);
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(() => { const locationValue = useMemo(() => {
if (!formik.values.location_id) return null; if (!formik.values.location_id) return null;
const found = locationOptions.find( return (
(opt) => String(opt.value) === formik.values.location_id locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
); );
if (found) return found; }, [formik.values.location_id, locationOptions]);
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(() => { const kandangValue = useMemo(() => {
if (!formik.values.kandang_id) return null; if (!formik.values.kandang_id) return null;
const found = kandangOptions.find( return (
(opt) => String(opt.value) === formik.values.kandang_id kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
); );
if (found) return found; }, [formik.values.kandang_id, kandangOptions]);
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(() => { const categoryValue = useMemo(() => {
if (!formik.values.category) return null; if (!formik.values.category) return null;
@@ -384,6 +345,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
}, [formik.values.category, categoryOptions]); }, [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 ===== // ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => { const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined; const areaId = area?.value ? String(area.value) : undefined;
@@ -452,11 +420,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
setRowSelection({}); setRowSelection({});
}; };
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('project-flock-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => { const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true); setSearchValue(e.target.value);
updateFilter('search', e.target.value);
}; };
const confirmApprovalHandler = async ( const confirmApprovalHandler = async (
notes: string, notes: string,
approvalAction: 'APPROVED' | 'REJECTED' approvalAction: 'APPROVED' | 'REJECTED'
@@ -503,14 +478,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
} }
}, [isChickinApproveModalOpen, chickinApproveModal]); }, [isChickinApproveModalOpen, chickinApproveModal]);
useEffect(() => {
if (isChickinDeleteModalOpen) {
chickinDeleteModal.openModal();
} else {
chickinDeleteModal.closeModal();
}
}, [isChickinDeleteModalOpen, chickinDeleteModal]);
useEffect(() => { useEffect(() => {
if (isClosingModalOpen) { if (isClosingModalOpen) {
closingModal.openModal(); closingModal.openModal();
@@ -574,7 +541,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
price: budget.price, price: budget.price,
total_price: budget.qty * budget.price, total_price: budget.qty * budget.price,
})) || [], })) || [],
periode: createdProjectFlock.period ?? '-',
} as ProjectFlockFormValues; } as ProjectFlockFormValues;
}, [createdProjectFlock]); }, [createdProjectFlock]);
@@ -797,6 +763,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[] []
); );
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
toast.error('Not implemented yet!');
setIsLoadingExportingToExcel(false);
};
const bulkApproveClickHandler = () => { const bulkApproveClickHandler = () => {
setApprovalAction('APPROVED'); setApprovalAction('APPROVED');
confirmModal.openModal(); confirmModal.openModal();
@@ -985,17 +959,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ButtonFilter <ButtonFilter
values={tableFilterState} values={tableFilterState}
excludeFields={[ excludeFields={['page', 'pageSize', 'search']}
'page',
'pageSize',
'search',
'area_name',
'location_name',
'kandang_name',
]}
onClick={handleFilterModalOpen} onClick={handleFilterModalOpen}
className='px-3 py-2.5' 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>
</div> </div>
@@ -1196,38 +1208,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
}} }}
/> />
{/* Chickin Delete Modal */}
<ConfirmationModal
ref={chickinDeleteModal.ref}
type='error'
text='Apakah anda yakin ingin menghapus data chick in ini?'
secondaryButton={{
text: 'Tidak',
onClick: () => {
closeChickinDeleteModal();
},
}}
className={{
modal: 'z-9999',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isChickinDeleteLoading,
onClick: async () => {
if (chickinDeleteCallback) {
setChickinDeleteLoading(true);
try {
await chickinDeleteCallback();
} finally {
setChickinDeleteLoading(false);
closeChickinDeleteModal();
}
}
},
}}
/>
{/* Filter Modal */} {/* Filter Modal */}
<Modal <Modal
ref={filterModal.ref} ref={filterModal.ref}
@@ -1324,14 +1304,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isClearable={true} isClearable={true}
/> />
<NumberInput <SelectInputRadio
label='Periode' label='Periode'
name='period' placeholder='Pilih Periode'
placeholder='Masukkan Periode' options={periodOptions}
value={formik.values.period ?? ''} value={periodValue}
onChange={formik.handleChange} onChange={(val) => {
onBlur={formik.handleBlur} if (!Array.isArray(val)) {
formik.setFieldValue('period', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable
/> />
</div> </div>
@@ -26,7 +26,6 @@ type ProjectFlockFormSchemaType = {
label: string; label: string;
} | null; } | null;
location_id: number; location_id: number;
periode: number | string;
kandang_ids: number[]; kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[]; project_budgets: ProjectFlockBudgetsSchemaType[];
}; };
@@ -110,12 +109,6 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'Lokasi wajib diisi!') .min(1, 'Lokasi wajib diisi!')
.required('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() kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!')) .of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!') .min(1, 'Minimal harus ada 1 kandang!')
@@ -152,10 +152,6 @@ export const ProjectFlockFormConfirmationTable = ({
label: 'Standar Produksi', label: 'Standar Produksi',
value: projectFlockForm?.production_standard?.label ?? '-', value: projectFlockForm?.production_standard?.label ?? '-',
}, },
{
label: 'Periode',
value: projectFlockForm?.periode ?? '-',
},
{ {
label: 'Informasi Kandang', label: 'Informasi Kandang',
value: '', value: '',
@@ -265,7 +261,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingFlocks, isLoadingOptions: isLoadingFlocks,
options: optionsFlock, options: optionsFlock,
loadMore: loadMoreFlock, loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', { } = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory, project_category: selectedCategory,
location_id: selectedLocation, location_id: selectedLocation,
area_id: selectedArea, area_id: selectedArea,
@@ -283,7 +279,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingLocations, isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation, setInputValue: setInputValueLocation,
loadMore: loadMoreLocation, loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', { } = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id: area_id:
selectedArea != '' selectedArea != ''
? selectedArea ? selectedArea
@@ -295,7 +291,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingProductionStandards, isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard, setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard, loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', { } = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
project_category: selectedCategory, project_category: selectedCategory,
}); });
@@ -311,7 +307,7 @@ const ProjectFlockForm = ({
} = useSWR(kandangUrl, KandangApi.getAllFetcher); } = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR( const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined, `${selectedFlock?.toString()}/periods`,
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string)) () => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
); );
@@ -533,7 +529,6 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map( kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id (k: Kandang) => k.id
) as number[], ) as number[],
periode: initialValues?.period ?? '',
project_budgets: initialValues?.project_budgets?.map((budget) => { project_budgets: initialValues?.project_budgets?.map((budget) => {
return { return {
nonstock: { nonstock: {
@@ -573,7 +568,6 @@ const ProjectFlockForm = ({
category: values.category as string, category: values.category as string,
production_standard_id: values.production_standard_id as number, production_standard_id: values.production_standard_id as number,
location_id: values.location_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[], kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => { project_budgets: values.project_budgets.flatMap((budget) => {
return { return {
@@ -799,7 +793,6 @@ const ProjectFlockForm = ({
formik.values.kandang_ids?.includes(kandang.id) formik.values.kandang_ids?.includes(kandang.id)
)?.period )?.period
: undefined; : undefined;
const inputPeriod = const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod; (initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
@@ -1029,18 +1022,12 @@ const ProjectFlockForm = ({
isDisabled={formType != 'add'} isDisabled={formType != 'add'}
/> />
<NumberInput <NumberInput
name='periode' name='period'
label='Periode' label='Periode'
disabled
readOnly
placeholder='Periode Flock' placeholder='Periode Flock'
value={formik.values.periode} value={selectedLocation ? inputPeriod : ''}
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> </div>
File diff suppressed because it is too large Load Diff
@@ -1,40 +1,15 @@
import { OptionType } from '@/components/input/SelectInput'; import { string, object } from 'yup';
import * as Yup from 'yup';
export const RecordingFilterSchema = Yup.object().shape({ export const RecordingFilterSchema = object().shape({
area_id: Yup.object({ area_id: string().nullable(),
value: Yup.number().nullable(), location_id: string().nullable(),
label: Yup.string().nullable(), kandang_id: string().nullable(),
}).nullable(), project_flock_kandang_id: string().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 = { export type RecordingFilterType = {
area_id: OptionType<number> | null; area_id: string | null;
location_id: OptionType<number> | null; location_id: string | null;
project_flock_id: OptionType<number> | null; kandang_id: string | null;
kandang_id: OptionType<number> | null; project_flock_kandang_id: string | null;
project_flock_kandang_id: number | null;
approval_status: OptionType<string> | null;
project_flock_category: OptionType<string> | null;
}; };
@@ -4,9 +4,7 @@ import {
CreateGrowingRecordingPayload, CreateGrowingRecordingPayload,
CreateLayingRecordingPayload, CreateLayingRecordingPayload,
CreateEggPayload, CreateEggPayload,
RecordingStock,
} from '@/types/api/production/recording'; } from '@/types/api/production/recording';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type RecordingGrowingFormSchemaType = { type RecordingGrowingFormSchemaType = {
record_date: string; record_date: string;
@@ -31,96 +29,63 @@ type RecordingGrowingFormSchemaType = {
} | null; } | null;
project_flock_kandang_id: number; project_flock_kandang_id: number;
stocks: { stocks: {
product_warehouse_id: product_warehouse_id: number;
| {
value: number;
label: string;
}
| undefined;
qty: number | string; qty: number | string;
}[]; }[];
depletions: { depletions: {
product_warehouse_id?: { product_warehouse_id?: number;
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string; qty?: number | string;
}[]; }[];
}; };
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & { type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: { eggs: {
product_warehouse_id?: { product_warehouse_id?: number;
value: number;
label: string;
} | null;
qty?: number | string; qty?: number | string;
weight?: number | string; weight?: number | string;
}[]; }[];
}; };
export type StockSchema = { export type StockSchema = {
product_warehouse_id: { product_warehouse_id: number;
value: number;
label: string;
};
qty: number | string; qty: number | string;
}; };
export type DepletionSchema = { export type DepletionSchema = {
product_warehouse_id?: { product_warehouse_id?: number;
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
qty?: number | string; qty?: number | string;
}; };
export type EggSchema = { export type EggSchema = {
product_warehouse_id?: { product_warehouse_id?: number;
value: number;
label: string;
} | null;
qty?: number | string; qty?: number | string;
weight?: number | string; weight?: number | string;
}; };
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
product_warehouse_id: Yup.object({ product_warehouse_id: Yup.number()
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.required('Produk wajib diisi!') .required('Produk wajib diisi!')
.typeError('Produk wajib diisi!'), .min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!') .required('Jumlah penggunaan wajib diisi!')
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!') .min(1, 'Jumlah penggunaan tidak boleh 0!')
.typeError('Jumlah penggunaan harus berupa angka!'), .typeError('Jumlah penggunaan harus berupa angka!'),
}); });
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.object({ product_warehouse_id: Yup.number()
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional() .optional()
.nullable(), .typeError('Depletions harus berupa angka!'),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
qty: Yup.number() qty: Yup.number()
.optional() .optional()
.typeError('Jumlah depletions harus berupa angka!'), .typeError('Jumlah depletions harus berupa angka!'),
}); });
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({ const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.object({ product_warehouse_id: Yup.number()
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.optional() .optional()
.nullable(), .typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'), qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'), weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
}); });
@@ -278,18 +243,14 @@ export const getRecordingGrowingFormInitialValues = (
initialValues?.project_flock?.project_flock_kandang_id ?? initialValues?.project_flock?.project_flock_kandang_id ??
0, 0,
stocks: initialValues?.stocks?.map((stock) => ({ stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: { product_warehouse_id: stock.product_warehouse_id,
value: stock.product_warehouse_id,
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: qty:
(stock as RecordingStock).qty || (stock as { qty?: number; usage_amount?: number }).qty ||
((stock as RecordingStock).usage_amount || 0) + (stock as { qty?: number; usage_amount?: number }).usage_amount ||
((stock as RecordingStock).pending_qty || 0) ||
'', '',
})) ?? [ })) ?? [
{ {
product_warehouse_id: undefined, product_warehouse_id: 0,
qty: '', qty: '',
}, },
], ],
@@ -297,16 +258,12 @@ export const getRecordingGrowingFormInitialValues = (
( (
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0] depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
) => ({ ) => ({
product_warehouse_id: { product_warehouse_id: depletion.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, qty: depletion.qty,
}) })
) ?? [ ) ?? [
{ {
product_warehouse_id: undefined, product_warehouse_id: 0,
qty: '', qty: '',
}, },
], ],
@@ -318,15 +275,12 @@ export const getRecordingLayingFormInitialValues = (
...getRecordingGrowingFormInitialValues(initialValues), ...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({ eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: { product_warehouse_id: egg.product_warehouse_id,
value: Number(egg.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(egg.product_warehouse),
},
qty: egg.qty, qty: egg.qty,
weight: egg.weight, weight: egg.weight,
})) ?? [ })) ?? [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
weight: '', weight: '',
}, },
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
isLaying: boolean,
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
@@ -50,18 +50,12 @@ const TransferToLayingConfirmationModalTable = ({
transferToLayingForm?: TransferToLayingFormValues; transferToLayingForm?: TransferToLayingFormValues;
transferToLayingId?: number; transferToLayingId?: number;
}) => { }) => {
const isValidId =
transferToLayingId !== undefined &&
transferToLayingId !== null &&
!isNaN(transferToLayingId) &&
transferToLayingId > 0;
const { data: transferToLaying, isLoading: isLoadingTransferToLaying } = const { data: transferToLaying, isLoading: isLoadingTransferToLaying } =
useSWR( useSWR(
isValidId transferToLayingId
? ['detail-transfer-to-laying', String(transferToLayingId)] ? ['detail-transfer-to-laying', String(transferToLayingId)]
: undefined, : undefined,
([, id]) => TransferToLayingApi.getSingle(Number(id)) ([id]) => TransferToLayingApi.getSingle(Number(id))
); );
const confirmationTableColumns: ColumnDef<TransferToLayingConfirmationTableDataType>[] = const confirmationTableColumns: ColumnDef<TransferToLayingConfirmationTableDataType>[] =
@@ -279,16 +273,12 @@ const TransferToLayingConfirmationModal = ({
{transferToLayingIds && {transferToLayingIds &&
!transferToLayingForm && !transferToLayingForm &&
transferToLayingIds transferToLayingIds.map((transferToLayingId, idx) => (
.filter( <TransferToLayingConfirmationModalTable
(id) => id !== undefined && id !== null && !isNaN(id) && id > 0 key={idx}
) transferToLayingId={transferToLayingId}
.map((transferToLayingId, idx) => ( />
<TransferToLayingConfirmationModalTable ))}
key={idx}
transferToLayingId={transferToLayingId}
/>
))}
{withNote && ( {withNote && (
<TextArea <TextArea
@@ -40,9 +40,6 @@ const TransferToLayingDetailModal = () => {
? transferToLayingResponse.data ? transferToLayingResponse.data
: undefined; : undefined;
const isTransferToLayingApproved =
transferToLaying?.approval.step_number === 2;
const { data: transferToLayingApprovalResponse } = useSWR( const { data: transferToLayingApprovalResponse } = useSWR(
transferToLayingId transferToLayingId
? ['approval-transfer-to-laying', transferToLayingId] ? ['approval-transfer-to-laying', transferToLayingId]
@@ -58,9 +55,9 @@ const TransferToLayingDetailModal = () => {
const detailModal = useModal(); const detailModal = useModal();
const maxSourceQuantity = const totalEnteredChickenForTransfer =
transferToLaying?.sources.reduce( transferToLaying?.sources.reduce(
(acc, item) => acc + Number(item.product_warehouse.quantity), (acc, item) => acc + Number(item.qty),
0 0
) ?? 0; ) ?? 0;
@@ -70,9 +67,8 @@ const TransferToLayingDetailModal = () => {
0 0
) ?? 0; ) ?? 0;
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer = const totalAvailableChickenForTransfer =
maxSourceQuantity - totalTransferedChicken; totalEnteredChickenForTransfer - totalTransferedChicken;
const closeModalHandler = (shouldPushToRoute: boolean = true) => { const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) { if (shouldPushToRoute) {
@@ -86,7 +82,7 @@ const TransferToLayingDetailModal = () => {
if (modalAction === 'detail') { if (modalAction === 'detail') {
detailModal.openModal(); detailModal.openModal();
} }
}, [modalAction]); }, [modalAction, detailModal]);
return ( return (
<Modal <Modal
@@ -165,34 +161,11 @@ const TransferToLayingDetailModal = () => {
{/* Source Kandang */} {/* Source Kandang */}
<div className='flex flex-col'> <div className='flex flex-col'>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'> <span className='w-full py-2 text-xs font-semibold'>
<span className='text-nowrap'> Kandang Asal{' '}
Kandang Asal{' '} <span className='tooltip tooltip-error' data-tip='required'>
<span className='tooltip tooltip-error' data-tip='required'> <span className='text-error'> *</span>
<span className='text-error'> *</span>
</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> </span>
{transferToLaying?.sources.length === 0 && ( {transferToLaying?.sources.length === 0 && (
@@ -252,6 +225,21 @@ const TransferToLayingDetailModal = () => {
<span className='text-error'> *</span> <span className='text-error'> *</span>
</span> </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> </span>
{transferToLaying?.targets.length === 0 && ( {transferToLaying?.targets.length === 0 && (
@@ -316,7 +304,7 @@ const TransferToLayingDetailModal = () => {
readOnly readOnly
errorMessage={ errorMessage={
totalAvailableChickenForTransfer < 0 totalAvailableChickenForTransfer < 0
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)` ? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
: '' : ''
} }
/> />
@@ -13,6 +13,7 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock'; import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import { import {
TransferToLayingFilterSchema, TransferToLayingFilterSchema,
TransferToLayingFilterValues, TransferToLayingFilterValues,
@@ -20,14 +21,12 @@ import {
interface TransferToLayingFilterModal { interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
initialValues?: Partial<TransferToLayingFilterValues>; onSubmit?: (values: TransferToLayingFilter) => void;
onSubmit?: (values: TransferToLayingFilterValues) => void;
onReset?: () => void; onReset?: () => void;
} }
const TransferToLayingFilterModal = ({ const TransferToLayingFilterModal = ({
ref, ref,
initialValues: initialValuesProp,
onSubmit, onSubmit,
onReset, onReset,
}: TransferToLayingFilterModal) => { }: TransferToLayingFilterModal) => {
@@ -87,16 +86,28 @@ const TransferToLayingFilterModal = ({
const formik = useFormik<TransferToLayingFilterValues>({ const formik = useFormik<TransferToLayingFilterValues>({
initialValues: { initialValues: {
startDate: initialValuesProp?.startDate ?? '', startDate: '',
endDate: initialValuesProp?.endDate ?? '', endDate: '',
flockSource: initialValuesProp?.flockSource ?? [], flockSource: [],
flockDestination: initialValuesProp?.flockDestination ?? [], flockDestination: [],
status: initialValuesProp?.status ?? [], status: [],
}, },
enableReinitialize: true,
validationSchema: TransferToLayingFilterSchema, validationSchema: TransferToLayingFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
onSubmit?.(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);
closeModalHandler(); closeModalHandler();
}, },
onReset: () => { onReset: () => {
@@ -223,16 +223,12 @@ const TransferToLayingFormModal = () => {
}, },
}); });
const { flockSource: formikFlockSource } = formik.values;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik); const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState< const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
ProjectFlock | undefined ProjectFlock | undefined
>(undefined); >(undefined);
const [maxSourceQuantity, setMaxSourceQuantity] = useState<number>(0);
const selectedFlockDestinationRawData = isResponseSuccess( const selectedFlockDestinationRawData = isResponseSuccess(
flockDestinationRawData flockDestinationRawData
) )
@@ -357,14 +353,19 @@ const TransferToLayingFormModal = () => {
return { available: countAvailable, unavailable: countUnavailable }; return { available: countAvailable, unavailable: countUnavailable };
}, [mappedFlockDestinationKandangsMaxTargetQty]); }, [mappedFlockDestinationKandangsMaxTargetQty]);
const totalEnteredChickenForTransfer =
formik.values.flockSourceKandangs.reduce(
(acc, item) => acc + Number(item.quantity),
0
);
const totalTransferedChicken = formik.values.flockDestinationKandangs.reduce( const totalTransferedChicken = formik.values.flockDestinationKandangs.reduce(
(acc, item) => acc + Number(item.quantity), (acc, item) => acc + Number(item.quantity),
0 0
); );
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer = const totalAvailableChickenForTransfer =
maxSourceQuantity - totalTransferedChicken; totalEnteredChickenForTransfer - totalTransferedChicken;
const isNextButtonDisabled = useMemo(() => { const isNextButtonDisabled = useMemo(() => {
if (step === 1) { if (step === 1) {
@@ -396,7 +397,6 @@ const TransferToLayingFormModal = () => {
formik.setFieldValue('maxTotalQuantity', ''); formik.setFieldValue('maxTotalQuantity', '');
formik.setFieldValue('reason', ''); formik.setFieldValue('reason', '');
formik.setFieldTouched('reason', false); formik.setFieldTouched('reason', false);
setMaxSourceQuantity(0);
setStep(2); setStep(2);
}; };
@@ -404,7 +404,6 @@ const TransferToLayingFormModal = () => {
const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => { const flockSourceChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('flockSource', val); formik.setFieldValue('flockSource', val);
formik.setFieldValue('flockSourceKandangs', []); formik.setFieldValue('flockSourceKandangs', []);
setMaxSourceQuantity(0);
}; };
const flockDestinationChangeHandler = ( const flockDestinationChangeHandler = (
@@ -457,39 +456,19 @@ const TransferToLayingFormModal = () => {
useEffect(() => { useEffect(() => {
if (isResponseSuccess(flockSourceRawData)) { if (isResponseSuccess(flockSourceRawData)) {
const currentSelectedFlockSourceRawData = flockSourceRawData.data.find( const selectedFlockSourceRawData = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value (item) => item.id === formik.values.flockSource?.value
); );
setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData); setSelectedFlockSourceRawData(selectedFlockSourceRawData);
} }
}, [flockSourceRawData, formikFlockSource]); }, [flockSourceRawData]);
useEffect(() => { useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken); formik.setFieldValue('totalQuantity', totalTransferedChicken);
formik.setFieldValue('maxTotalQuantity', totalTransferedChicken); formik.setFieldValue('maxTotalQuantity', totalTransferedChicken);
}, [totalTransferedChicken, formik.values.flockDestinationKandangs]); }, [totalTransferedChicken, formik.values.flockDestinationKandangs]);
// Auto-fill source kandang quantity from total destination quantity
useEffect(() => {
if (formik.values.flockSourceKandangs.length > 0) {
formik.setFieldValue(
'flockSourceKandangs.0.quantity',
totalTransferedChicken
);
}
}, [totalTransferedChicken]);
useEffect(() => {
if (
formik.values.flockSourceKandangs.length > 0 &&
formik.values.flockSourceKandangs[0].maxQuantity &&
maxSourceQuantity === 0
) {
setMaxSourceQuantity(formik.values.flockSourceKandangs[0].maxQuantity);
}
}, [formik.values.flockSourceKandangs, maxSourceQuantity]);
return ( return (
<> <>
<Modal <Modal
@@ -604,9 +583,14 @@ const TransferToLayingFormModal = () => {
k.kandang.value === item.project_flock_kandang_id k.kandang.value === item.project_flock_kandang_id
); );
const flockSourceKandangRadioChangeHandler = () => { const flockSourceKandangCheckboxChangeHandler: FormEventHandler<
if (isAvailable) { HTMLInputElement
> = (e) => {
const checked = (e.target as HTMLInputElement)
.checked;
if (checked) {
formik.setFieldValue('flockSourceKandangs', [ formik.setFieldValue('flockSourceKandangs', [
...formik.values.flockSourceKandangs,
{ {
kandang: { kandang: {
value: item.project_flock_kandang_id, value: item.project_flock_kandang_id,
@@ -616,7 +600,15 @@ const TransferToLayingFormModal = () => {
maxQuantity: item.available_qty, maxQuantity: item.available_qty,
}, },
]); ]);
setMaxSourceQuantity(item.available_qty); } else {
formik.setFieldValue(
'flockSourceKandangs',
formik.values.flockSourceKandangs.filter(
(k) =>
k.kandang.value !==
item.project_flock_kandang_id
)
);
} }
}; };
@@ -626,28 +618,32 @@ const TransferToLayingFormModal = () => {
className='w-full p-3 flex flex-row items-center justify-between' className='w-full p-3 flex flex-row items-center justify-between'
> >
<div className='flex flex-row items-center gap-3'> <div className='flex flex-row items-center gap-3'>
<input <CheckboxInput
id={`flock-source-kandang-${item.project_flock_kandang_id}`} name={`flockSourceKandang.${itemIdx}.value`}
type='radio'
name='flockSourceKandang'
value={item.project_flock_kandang_id} value={item.project_flock_kandang_id}
checked={isChecked} checked={isChecked}
onChange={flockSourceKandangRadioChangeHandler} onChange={
flockSourceKandangCheckboxChangeHandler
}
size='md'
disabled={!isAvailable} disabled={!isAvailable}
className={cn('radio radio-md radio-primary', { classNames={{
'opacity-50 cursor-not-allowed': !isAvailable, checkbox: cn({
})} 'bg-base-200 border border-base-content/10 opacity-100':
!isAvailable,
}),
}}
/> />
<label <label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`} htmlFor={`flockSourceKandang.${itemIdx}.value`}
className={cn('text-sm text-base-content/50', { className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable, 'cursor-pointer': isAvailable,
'cursor-not-allowed opacity-50': !isAvailable, 'cursor-not-allowed': !isAvailable,
})} })}
> >
{item.kandang_name}{' '} {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> </label>
</div> </div>
@@ -822,33 +818,11 @@ const TransferToLayingFormModal = () => {
{/* Source Kandang */} {/* Source Kandang */}
<div className='flex flex-col'> <div className='flex flex-col'>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'> <span className='w-full py-2 text-xs font-semibold'>
<span className='text-nowrap'> Kandang Asal{' '}
Kandang Asal{' '} <span className='tooltip tooltip-error' data-tip='required'>
<span <span className='text-error'> *</span>
className='tooltip tooltip-error'
data-tip='required'
>
<span className='text-error'> *</span>
</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> </span>
{formik.values.flockSourceKandangs.length === 0 && ( {formik.values.flockSourceKandangs.length === 0 && (
@@ -884,7 +858,7 @@ const TransferToLayingFormModal = () => {
<NumberInput <NumberInput
key={`flockSourceKandangs-${item.kandang.value}-${index}`} key={`flockSourceKandangs-${item.kandang.value}-${index}`}
name={`flockSourceKandangs.${index}.quantity`} name={`flockSourceKandangs.${index}.quantity`}
placeholder='Masukkan Kuantitas pada Kandang Tujuan' placeholder='Masukkan Kuantitas'
value={item.quantity} value={item.quantity}
onChange={formik.handleChange} onChange={formik.handleChange}
isError={isInvalid} isError={isInvalid}
@@ -901,8 +875,6 @@ const TransferToLayingFormModal = () => {
<div className='w-px bg-base-content/10' /> <div className='w-px bg-base-content/10' />
</div> </div>
} }
readOnly
disabled
className={{ className={{
inputPrefix: inputPrefix:
'py-0 px-0 pl-3 text-base-content/50 bg-transparent border-r-0', 'py-0 px-0 pl-3 text-base-content/50 bg-transparent border-r-0',
@@ -928,6 +900,23 @@ const TransferToLayingFormModal = () => {
<span className='text-error'> *</span> <span className='text-error'> *</span>
</span> </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> </span>
{formik.values.flockDestinationKandangs.length === 0 && ( {formik.values.flockDestinationKandangs.length === 0 && (
@@ -1011,7 +1000,7 @@ const TransferToLayingFormModal = () => {
isError={totalAvailableChickenForTransfer < 0} isError={totalAvailableChickenForTransfer < 0}
errorMessage={ errorMessage={
totalAvailableChickenForTransfer < 0 totalAvailableChickenForTransfer < 0
? `Jumlah transfer melebihi ketersediaan (${formatNumber(maxSourceQuantity, 'en-US')} ayam)` ? `Jumlah transfer melebihi ketersediaan (${formatNumber(totalEnteredChickenForTransfer, 'en-US')} ayam)`
: '' : ''
} }
disabled disabled
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react'; import { ChangeEventHandler, useEffect, useState } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store'; import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -26,9 +26,10 @@ import TransferToLayingFilterModal from '@/components/pages/production/transfer-
import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal'; import TransferToLayingConfirmationModal from '@/components/pages/production/transfer-to-laying/TransferToLayingConfirmationModal';
import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton'; import TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying'; import {
import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter'; TransferToLaying,
import { OptionType } from '@/components/input/SelectInput'; TransferToLayingFilter,
} from '@/types/api/production/transfer-to-laying';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying'; import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -47,11 +48,11 @@ const RowOptionsMenu = ({
popoverPosition: 'bottom' | 'top'; popoverPosition: 'bottom' | 'top';
deleteClickHandler: () => void; deleteClickHandler: () => void;
}) => { }) => {
const showEditButton = props.row.original.approval.action !== 'APPROVED'; const showEditButton =
props.row.original.approval.action !== 'APPROVED' &&
props.row.original.approval.action !== 'REJECTED';
const showDeleteButton = const showDeleteButton = showEditButton;
props.row.original.approval.action === 'APPROVED' ||
props.row.original.approval.step_name.toLowerCase() === 'pengajuan';
const popoverId = `transferToLaying#${props.row.original.id}`; const popoverId = `transferToLaying#${props.row.original.id}`;
const popoverAnchorName = `--anchor-transferToLaying#${props.row.original.id}`; const popoverAnchorName = `--anchor-transferToLaying#${props.row.original.id}`;
@@ -141,8 +142,6 @@ const TransferToLayingsTable = () => {
status: '', status: '',
filter_by: '', filter_by: '',
sort_by: '', sort_by: '',
flockSourceNames: '',
flockDestinationNames: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -155,9 +154,6 @@ const TransferToLayingsTable = () => {
filter_by: 'filter_by', filter_by: 'filter_by',
sort_by: 'sort_by', sort_by: 'sort_by',
}, },
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
persist: true,
storeName: 'transfer-to-laying-table',
}); });
const { const {
@@ -435,84 +431,12 @@ const TransferToLayingsTable = () => {
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}; };
const STATUS_FILTER_OPTIONS = [ const filterSubmitHandler = (values: TransferToLayingFilter) => {
{ value: 'PENDING', label: 'Pengajuan' }, updateFilter('startDate', values.startDate);
{ value: 'APPROVED', label: 'Disetujui' }, updateFilter('endDate', values.endDate);
{ value: 'REJECTED', label: 'Ditolak' }, updateFilter('flockSource', values.flockSource.join(','));
]; updateFilter('flockDestination', values.flockDestination.join(','));
updateFilter('status', values.status.join(','));
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 = () => { const filterResetHandler = () => {
@@ -521,8 +445,6 @@ const TransferToLayingsTable = () => {
updateFilter('flockSource', ''); updateFilter('flockSource', '');
updateFilter('flockDestination', ''); updateFilter('flockDestination', '');
updateFilter('status', ''); updateFilter('status', '');
updateFilter('flockSourceNames', '');
updateFilter('flockDestinationNames', '');
}; };
const exportToExcelHandler = async () => { const exportToExcelHandler = async () => {
@@ -541,7 +463,7 @@ const TransferToLayingsTable = () => {
updateFilter('filter_by', ''); updateFilter('filter_by', '');
updateFilter('sort_by', ''); updateFilter('sort_by', '');
} }
}, [sorting]); }, [sorting, updateFilter]);
return ( return (
<> <>
@@ -636,8 +558,6 @@ const TransferToLayingsTable = () => {
'search', 'search',
'filter_by', 'filter_by',
'sort_by', 'sort_by',
'flockSourceNames',
'flockDestinationNames',
]} ]}
fieldGroups={[['startDate', 'endDate']]} fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal} onClick={filterModal.openModal}
@@ -750,7 +670,6 @@ const TransferToLayingsTable = () => {
<TransferToLayingFilterModal <TransferToLayingFilterModal
ref={filterModal.ref} ref={filterModal.ref}
initialValues={filterModalInitialValues}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
/> />
@@ -60,25 +60,6 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
router.push(`/production/uniformity?action=reject&id=${initialValues.id}`); router.push(`/production/uniformity?action=reject&id=${initialValues.id}`);
}; };
const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={uniformity_details}
uniformityId={initialValues.id}
/>
);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
};
useEffect(() => { useEffect(() => {
if ( if (
shouldFetchDetails && shouldFetchDetails &&
@@ -202,6 +183,25 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
} }
if (id === 'document-name') { if (id === 'document-name') {
const handleViewUniformityDetails = () => {
if (!uniformity_details || uniformity_details.length === 0) {
setShouldFetchDetails(true);
return;
}
setExpandedDrawerContent(
<UniformityDetailsPreview
info_umum={initialValues.info_umum}
uniformity_details={uniformity_details}
uniformityId={initialValues.id}
/>
);
setTimeout(() => {
setExpandedDrawerOpen(true);
}, 0);
};
return ( return (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span>{valueMap[id]}</span> <span>{valueMap[id]}</span>
@@ -231,7 +231,14 @@ const UniformityDetail: React.FC<UniformityDetailProps> = ({
}, },
}, },
], ],
[initialValues, handleViewUniformityDetails, isLoading] [
initialValues,
isLoading,
uniformity_details,
setShouldFetchDetails,
setExpandedDrawerContent,
setExpandedDrawerOpen,
]
); );
const samplingTableData: DetailOptionType[] = useMemo(() => { const samplingTableData: DetailOptionType[] = useMemo(() => {
@@ -3,6 +3,7 @@ import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = { type UniformityFormSchemaType = {
date: string; date: string;
week: number;
location?: { location?: {
value: number; value: number;
label: string; label: string;
@@ -44,6 +45,10 @@ const FileSchema = Yup.mixed<File>()
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> = export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({ Yup.object({
date: Yup.string().required('Tanggal wajib diisi!'), date: Yup.string().required('Tanggal wajib diisi!'),
week: Yup.number()
.min(1, 'Minggu ke wajib diisi!')
.required('Minggu ke wajib diisi!')
.typeError('Minggu ke wajib diisi!'),
location: Yup.object({ location: Yup.object({
value: Yup.number().min(1).required(), value: Yup.number().min(1).required(),
label: Yup.string().required(), label: Yup.string().required(),
@@ -76,6 +81,7 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = { export type UniformityFormData = {
date: string; date: string;
week: number;
project_flock_kandang_id: number; project_flock_kandang_id: number;
document: File | null; document: File | null;
document_name: string; document_name: string;
@@ -85,7 +91,8 @@ export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity> initialValues?: Partial<Uniformity>
): UniformityFormValues => { ): UniformityFormValues => {
return { return {
date: '', date: initialValues?.week ? '' : '',
week: initialValues?.week ?? 0,
location: null, location: null,
location_id: 0, location_id: 0,
project_flock: null, project_flock: null,
@@ -27,6 +27,7 @@ import { LocationApi } from '@/services/api/master-data';
import { import {
ProjectFlockApi, ProjectFlockApi,
ProjectFlockKandangApi, ProjectFlockKandangApi,
RecordingApi,
} from '@/services/api/production'; } from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity'; import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -39,6 +40,7 @@ import {
ProjectFlockKandangLookup, ProjectFlockKandangLookup,
ProjectFlock, ProjectFlock,
} from '@/types/api/production/project-flock'; } from '@/types/api/production/project-flock';
import { Recording } from '@/types/api/production/recording';
import { Kandang } from '@/types/api/master-data/kandang'; import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm'; import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm'; import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -202,6 +204,23 @@ const UniformityForm = ({
? projectFlockKandangLookupData.data ? projectFlockKandangLookupData.data
: undefined; : undefined;
// ===== RECORDINGS DATA (FOR WEEK CALCULATION) =====
const recordingsUrl = useMemo(() => {
if (!projectFlockKandangLookup?.project_flock_kandang_id) return null;
const params = new URLSearchParams({
page: '1',
limit: '100',
project_flock_kandang_id:
projectFlockKandangLookup.project_flock_kandang_id.toString(),
});
return `${RecordingApi.basePath}?${params.toString()}`;
}, [projectFlockKandangLookup?.project_flock_kandang_id]);
const { data: recordingsData } = useSWR(
recordingsUrl,
recordingsUrl ? RecordingApi.getAllFetcher : null
);
// ===== FORM CONFIGURATION ===== // ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<UniformityFormValues>( const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues), () => getUniformityFormInitialValues(initialValues),
@@ -227,6 +246,7 @@ const UniformityForm = ({
setUniformityFormData({ setUniformityFormData({
date: values.date, date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId, project_flock_kandang_id: projectFlockKandangId,
document: values.document as File, document: values.document as File,
document_name: (values.document as File).name, document_name: (values.document as File).name,
@@ -455,6 +475,59 @@ const UniformityForm = ({
generateUniformityTemplate(population, projectFlockKandangLookup); generateUniformityTemplate(population, projectFlockKandangLookup);
}, [projectFlockKandangLookup]); }, [projectFlockKandangLookup]);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (
projectFlockKandangLookup?.chick_in_date &&
projectFlockKandangLookup?.project_flock_kandang_id
) {
const chickInDate = new Date(projectFlockKandangLookup.chick_in_date);
chickInDate.setHours(0, 0, 0, 0);
let initialWeek = 18;
if (
isResponseSuccess(recordingsData) &&
recordingsData.data &&
recordingsData.data.length > 0
) {
const sortedRecordings = [...recordingsData.data].sort(
(a: Recording, b: Recording) =>
new Date(a.record_datetime).getTime() -
new Date(b.record_datetime).getTime()
);
const earliestRecording = sortedRecordings[0];
if (earliestRecording?.project_flock?.production_standart?.week) {
initialWeek =
earliestRecording.project_flock.production_standart.week;
}
}
if (formik.values.date) {
const selectedDate = new Date(formik.values.date);
selectedDate.setHours(0, 0, 0, 0);
const daysDiff = Math.floor(
(selectedDate.getTime() - chickInDate.getTime()) /
(1000 * 60 * 60 * 24)
);
const weeksDiff = Math.floor(daysDiff / 7);
setFieldValue('week', initialWeek + weeksDiff);
} else {
setFieldValue('week', initialWeek);
}
}
}, [
projectFlockKandangLookup?.chick_in_date,
projectFlockKandangLookup?.project_flock_kandang_id,
recordingsData,
formik.values.date,
setFieldValue,
]);
useEffect(() => { useEffect(() => {
const unsub = subscribeValidate(() => { const unsub = subscribeValidate(() => {
setIsValid(true); setIsValid(true);
@@ -524,7 +597,6 @@ const UniformityForm = ({
onBlur={formik.handleBlur} onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)} isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string} errorMessage={formik.errors.date as string}
disabled={isNextStep}
/> />
<SelectInput <SelectInput
@@ -543,7 +615,6 @@ const UniformityForm = ({
errorMessage={formik.errors.location_id as string} errorMessage={formik.errors.location_id as string}
isClearable isClearable
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isDisabled={isNextStep}
/> />
<SelectInput <SelectInput
@@ -556,7 +627,7 @@ const UniformityForm = ({
onInputChange={setProjectFlockSearchValue} onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks} onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id || isNextStep} isDisabled={!formik.values.location_id}
isError={ isError={
formik.touched.project_flock_id && formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id) Boolean(formik.errors.project_flock_id)
@@ -573,7 +644,7 @@ const UniformityForm = ({
value={formik.values.kandang} value={formik.values.kandang}
onChange={handleKandangChange} onChange={handleKandangChange}
options={kandangOptions} options={kandangOptions}
isDisabled={!formik.values.project_flock_id || isNextStep} isDisabled={!formik.values.project_flock_id}
isError={ isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id) formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
} }

Some files were not shown because too many files have changed in this diff Show More