Compare commits

..

1 Commits

133 changed files with 4096 additions and 11193 deletions
-3
View File
@@ -48,6 +48,3 @@ next-env.d.ts
# rtk # rtk
rtk.exe rtk.exe
# local specs
/local-specs
+2 -21
View File
@@ -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:
@@ -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
@@ -200,7 +185,7 @@ deploy:staging:
url: https://stg-lti-erp.mbugroup.id url: https://stg-lti-erp.mbugroup.id
# ========================================================== # ==========================================================
# ====== (Branch production) ====== # ====== STAGING (Branch production) ======
# ========================================================== # ==========================================================
build:production: build:production:
<<: *build_template <<: *build_template
@@ -213,10 +198,6 @@ build:production:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-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_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia' 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:production:
<<: *deploy_template <<: *deploy_template
+1 -2
View File
@@ -1,4 +1,3 @@
npm run format npm run format
npm run lint npm run lint
npm run typecheck npm run typecheck
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"
-486
View File
@@ -1,486 +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**
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
- Call `filterModal.closeModal()` at the end
- Attach to form `onReset` handler (not `formik.handleReset`)
```tsx
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
filterModal.closeModal();
};
// ...
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
```
**Optimization: Avoid useCallback and useMemo for trivial operations**
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
- Simple derivations and pass-through handlers don't need them:
```tsx
// ✅ Good: plain derivation
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
const meta =
isResponseSuccess(response) && response.meta ? response.meta : null;
// ❌ Avoid: useMemo for trivial conditional access
const data = useMemo(
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
[response]
);
// ✅ Good: simple handler
const handleChange = (val) => setFieldValue('location', val);
// ❌ Avoid: unnecessary useCallback
const handleChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
```
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
**Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically:
- `OptionType<T>` → serialized as `String(value)` in the query string
- `OptionType<T>[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids`
```tsx
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
customers: OptionType<number>[]; // multi-select → serializes as CSV
location?: OptionType<string>; // single-select → serializes as value string
filterBy?: OptionType<string>; // single-select radio
}>({
initial: {
search: '',
customers: [],
location: undefined,
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
location: 'location_id', // serializes OptionType → "abc"
filterBy: 'filter_by',
},
persist: true,
storeName: 'my-table',
});
// Initialize formik directly from tableFilterState (no hardcoded defaults)
const formik = useFormik({
initialValues: {
customers: tableFilterState.customers,
location: tableFilterState.location,
filterBy: tableFilterState.filterBy,
},
...
});
// Use formik values directly — no computed helpers needed
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
```
**Filter field naming convention**
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
```tsx
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
// ❌ Avoid: unnecessary ref indirection
const handleFilterModalOpenRef = useRef(() => {});
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
// ✅ Correct: pass openModal directly
<ButtonFilter onClick={filterModal.openModal} ... />
```
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
**Apply this pattern to:**
- Any data table component across any module that needs persistent filters
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
- Whenever users' filter/search/pagination choices should survive page refreshes
**Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range
## SWR fetch pattern
Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type:
```tsx
// ✅ Same type as service generic — use getAllFetcher
const { data } = useSWR(
`${Api.basePath}${getTableFilterQueryString()}`,
Api.getAllFetcher
);
// ✅ Different type — use httpClientFetcher with explicit useSWR type
const { data } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
```
Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`.
## Server-side sorting pattern
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
**Four-part wiring:**
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
```ts
initial: { sort_by: '', order_by: '' }
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
```
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
```ts
useEffect(() => {
if (sorting.length > 0) {
updateFilter('sort_by', sorting[0].id, true);
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '');
updateFilter('order_by', '');
}
}, [sorting]);
```
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
```tsx
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
```
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
## Server-side file export pattern
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
### Service method (in `src/services/api/{feature}.ts`)
```ts
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
}
```
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
- Add one method per format; keep them side-by-side in the same service class.
### Component handler (in the page/tab component)
```ts
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const params = new URLSearchParams();
if (filterParams.foo) params.set('foo', filterParams.foo);
// ... map all active filter params ...
await FeatureApi.exportToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, searchValue]);
```
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (60-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk go test # Go test failures only (90%)
rtk jest # Jest failures only (99.5%)
rtk vitest # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk pytest # Python test failures only (90%)
rtk rake test # Ruby test failures only (90%)
rtk rspec # RSpec test failures only (60%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
| ---------------- | ------------------------------ | --------------- |
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+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
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
-11
View File
@@ -1,11 +0,0 @@
import SuspenseHelper from '@/components/helper/SuspenseHelper';
const Layout = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
return <SuspenseHelper>{children}</SuspenseHelper>;
};
export default Layout;
+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(),
+11 -14
View File
@@ -6,7 +6,6 @@ export interface TabItem {
label: ReactNode; label: ReactNode;
content?: ReactNode; content?: ReactNode;
disabled?: boolean; disabled?: boolean;
hide?: boolean;
} }
export interface TabsProps export interface TabsProps
@@ -123,19 +122,17 @@ const Tabs = ({
> >
<div className={getSideContentClasses()}> <div className={getSideContentClasses()}>
<div role='tablist' className={getTabsClasses()}> <div role='tablist' className={getTabsClasses()}>
{tabs.map(({ id, label, disabled, hide }) => {tabs.map(({ id, label, disabled }) => (
hide ? null : ( <button
<button key={id}
key={id} role='tab'
role='tab' className={getTabClasses(id === activeTabId, disabled)}
className={getTabClasses(id === activeTabId, disabled)} onClick={() => !disabled && handleTabChange(id)}
onClick={() => !disabled && handleTabChange(id)} disabled={disabled}
disabled={disabled} >
> {label}
{label} </button>
</button> ))}
)
)}
</div> </div>
{sideContent && sideContent} {sideContent && sideContent}
</div> </div>
+1 -1
View File
@@ -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();
@@ -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) {
@@ -173,8 +173,8 @@ const DashboardProduction = () => {
loadMore: loadMoreKandang, loadMore: loadMoreKandang,
} = useSelect<ProjectFlockKandang>( } = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath, ProjectFlockKandangApi.basePath,
'kandang_id', 'id',
'kandang.name', 'name_with_period',
'search', 'search',
{ {
location_id: location_id:
@@ -362,7 +362,7 @@ const DashboardProduction = () => {
</div> </div>
{/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */} {/* Hidden container for all charts (used for PDF export in OVERVIEW mode) */}
{dashboardProductionData && ( {exporting && dashboardProductionData && (
<> <>
{/* Export Stats Charts */} {/* Export Stats Charts */}
<div <div
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Button from '@/components/Button'; import Button from '@/components/Button';
@@ -10,14 +10,16 @@ import ExpenseRequestContent from '@/components/pages/expense/ExpenseRequestCont
import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent'; import ExpenseRealizationContent from '@/components/pages/expense/ExpenseRealizationContent';
import { Expense } from '@/types/api/expense'; import { Expense } from '@/types/api/expense';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
interface ExpenseDetailProps { interface ExpenseDetailProps {
initialValues?: Expense; initialValues?: Expense;
} }
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 searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const expenseDetailTabs = useMemo(() => { const expenseDetailTabs = useMemo(() => {
const validTabs = [ const validTabs = [
@@ -48,8 +50,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={returnTo}
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} />
@@ -2,7 +2,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } 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,7 +26,7 @@ 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';
@@ -48,11 +46,6 @@ const ExpenseRequestContent = ({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams); 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 +95,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 +146,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);
@@ -426,24 +388,6 @@ 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'>
@@ -589,19 +533,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 +548,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 +758,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 */}
+149 -256
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';
@@ -29,7 +30,7 @@ import {
FINANCE_TRANSACTION_TYPE_OPTIONS, FINANCE_TRANSACTION_TYPE_OPTIONS,
} from '@/config/constant'; } from '@/config/constant';
import { FinanceApi } from '@/services/api/finance'; import { FinanceApi } from '@/services/api/finance';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data'; import { BankApi, CustomerApi, SupplierApi } from '@/services/api/master-data';
import { Bank } from '@/types/api/master-data/bank'; import { Bank } from '@/types/api/master-data/bank';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
@@ -38,8 +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 Dropdown from '@/components/dropdown/Dropdown';
import { import {
FinanceTableFilterSchema, FinanceTableFilterSchema,
FinanceTableFilterValues, FinanceTableFilterValues,
@@ -176,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,
@@ -184,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',
@@ -204,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 =====
@@ -234,14 +229,13 @@ const FinanceTable = () => {
const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null); const [selectedSortBy, setSelectedSortBy] = useState<OptionType | null>(null);
const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null); const [selectedFinance, setSelectedFinance] = useState<Finance | null>(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isExportLoading, setIsExportLoading] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
// ===== 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: '',
@@ -251,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();
}, },
}); });
@@ -345,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
@@ -404,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;
@@ -484,88 +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 exportToExcel = async () => { const resetFilterHandler = () => {
setIsExportLoading(true); setSelectedTransactionType(null);
try { setSelectedBank(null);
await FinanceApi.exportToExcel(getTableFilterQueryString()); setSelectedCustomerId(null);
toast.success('Excel berhasil dibuat dan diunduh.'); setSelectedSupplierId(null);
} catch (error) { setSelectedSortBy(null);
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data finance.') filterFormik.resetForm();
);
} finally { updateFilter('search', '');
setIsExportLoading(false); resetSearchValue();
} updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
}; };
const confirmationModalDeleteClickHandler = async () => { const confirmationModalDeleteClickHandler = async () => {
@@ -584,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>;
@@ -598,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('_')
@@ -608,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>;
@@ -618,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>;
@@ -642,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',
@@ -700,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'>
@@ -761,65 +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',
{
<Dropdown 'border-primary-gradient text-primary': hasFilters,
align='end' }
direction='bottom' )}
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Ekspor</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
> >
<Button <Icon icon='heroicons:funnel' width={20} height={20} />
variant='ghost' Filter
color='none' {hasFilters && (
onClick={exportToExcel} <span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
isLoading={isExportLoading} {activeFiltersCount}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' </span>
> )}
<Icon icon='heroicons:table-cells' width={20} height={20} /> </Button>
Ekspor ke Excel
</Button>
</Dropdown>
</div> </div>
</div> </div>
@@ -855,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',
@@ -991,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,7 +7,8 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import useSWR from 'swr'; import { usePathname } from 'next/navigation';
import useSWR, { mutate } 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';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -24,6 +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 { useUiStore } from '@/stores/ui/ui.store';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
@@ -98,31 +100,25 @@ const RowOptionsMenu = ({
}; };
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 +131,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 +139,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: 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', String(values.warehouse?.value) || '');
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,9 +193,14 @@ 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 = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
@@ -214,20 +208,38 @@ const InventoryAdjustmentTable = () => {
formik.setFieldValue('warehouse', val); formik.setFieldValue('warehouse', val);
}; };
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 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 {
@@ -264,8 +276,17 @@ const InventoryAdjustmentTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal(); const singleDeleteModal = useModal();
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
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(
@@ -486,8 +507,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 +596,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}
@@ -601,7 +620,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 +630,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>
@@ -1,23 +1,14 @@
import { string, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
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>; transaction_type: string | null;
transaction_type?: OptionType<string>; warehouse: OptionType<number> | null;
}; };
@@ -1,7 +1,14 @@
'use client'; 'use client';
import { ChangeEventHandler, useMemo, useState } from 'react'; import {
import useSWR from 'swr'; ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR, { mutate } 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,6 +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 { useUiStore } from '@/stores/ui/ui.store';
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 Button from '@/components/Button'; import Button from '@/components/Button';
@@ -100,21 +108,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 +129,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 +137,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,23 +180,47 @@ 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>([]);
@@ -227,8 +255,17 @@ const MovementTable = () => {
} }
}; };
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('movement-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 movementColumns: ColumnDef<Movement>[] = useMemo( const movementColumns: ColumnDef<Movement>[] = useMemo(
@@ -427,7 +464,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 +476,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 +489,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>
@@ -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;
}; };
@@ -56,9 +56,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const [productQtyErrorShown, setProductQtyErrorShown] = useState(false); const [productQtyErrorShown, setProductQtyErrorShown] = useState(false);
const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false); const [deliveryQtyErrorShown, setDeliveryQtyErrorShown] = useState(false);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const productStockCacheRef = useRef<
Map<number, { quantity: number; transfer_available_qty?: number }>
>(new Map());
// ===== FORM HANDLERS ===== // ===== FORM HANDLERS =====
const createMovementHandler = useCallback( const createMovementHandler = useCallback(
@@ -340,7 +337,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
prevSourceWarehouseId !== currentSourceWarehouseId && prevSourceWarehouseId !== currentSourceWarehouseId &&
prevSourceWarehouseId !== null prevSourceWarehouseId !== null
) { ) {
productStockCacheRef.current = new Map();
formik.setFieldValue('products', [ formik.setFieldValue('products', [
{ {
product: null, product: null,
@@ -403,15 +399,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
: []; : [];
}, [productWarehouses]); }, [productWarehouses]);
useEffect(() => {
productWarehouseOptions.forEach((pw) => {
productStockCacheRef.current.set(pw.product_id, {
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
});
});
}, [productWarehouseOptions]);
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
const isRepeaterInputError = <T extends 'products' | 'deliveries'>( const isRepeaterInputError = <T extends 'products' | 'deliveries'>(
arrayName: T, arrayName: T,
@@ -853,12 +840,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getAvailableStock = useCallback( const getAvailableStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.transfer_available_qty ?? live.quantity ?? 0;
const cached = productStockCacheRef.current.get(productId); return (
return cached?.transfer_available_qty ?? cached?.quantity ?? 0; productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
}, },
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
@@ -866,25 +856,20 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
const getTotalStock = useCallback( const getTotalStock = useCallback(
(productId: number) => { (productId: number) => {
if (type === 'detail') return 0; if (type === 'detail') return 0;
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.quantity ?? 0; return productWarehouse?.quantity ?? 0;
return productStockCacheRef.current.get(productId)?.quantity ?? 0;
}, },
[productWarehouseOptions, type] [productWarehouseOptions, type]
); );
const hasAvailableQty = useCallback( const hasAvailableQty = useCallback(
(productId: number) => { (productId: number) => {
const live = productWarehouseOptions.find( const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId (pw) => pw.product_id === productId
); );
if (live) return live.transfer_available_qty !== undefined; return productWarehouse?.transfer_available_qty !== undefined;
return (
productStockCacheRef.current.get(productId)?.transfer_available_qty !==
undefined
);
}, },
[productWarehouseOptions] [productWarehouseOptions]
); );
@@ -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',
@@ -849,11 +849,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';
@@ -17,31 +17,20 @@ import {
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 { CustomerApi } 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,13 +38,36 @@ 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>(
include_all: 'true', MarketingApi.basePath,
}); 'id',
'so_number',
'search'
);
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
const { const {
options: customersOptions, options: customersOptions,
@@ -66,19 +78,6 @@ const MarketingFilterModal = ({
has_marketing: 'true', has_marketing: 'true',
}); });
const {
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
setInputValue: setProjectFlockInputValue,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search'
);
const statusOptions = [ const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({ ...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(), value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -88,29 +87,18 @@ const MarketingFilterModal = ({
]; ];
const formik = useFormik<MarketingFilterFormValues>({ const formik = useFormik<MarketingFilterFormValues>({
initialValues: initialValues || { initialValues: {
product_ids: [], product_ids: [],
status: null, status: null,
customer: null, customer: null,
project_flock: null,
project_flock_kandang: null,
}, },
validationSchema: MarketingFilterSchema, validationSchema: MarketingFilterSchema,
onSubmit: async (values) => { onSubmit: async (values) => {
const formattedValues: MarketingFilter = { const formattedValues: MarketingFilter = {
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?.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,22 +111,6 @@ 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[]);
}; };
@@ -154,27 +126,6 @@ const MarketingFilterModal = ({
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 +135,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 */}
@@ -241,37 +192,6 @@ const MarketingFilterModal = ({
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 */}
+72 -590
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,13 +226,10 @@ const MarketingTable = () => {
confirmationModal.openModal(); confirmationModal.openModal();
}; };
const productsClickHandler = useCallback( const productsClickHandler = (item: Marketing) => {
(item: Marketing) => { setSelectedItem(item);
setSelectedItem(item); productsModal.openModal();
productsModal.openModal(); };
},
[productsModal]
);
const deleteMarketingHandler = async () => { const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete( const deleteMarketingRes = await MarketingApi.delete(
@@ -357,226 +251,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 +329,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 +336,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 +378,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 +394,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 +429,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,7 +449,6 @@ const MarketingTable = () => {
{ {
accessorKey: 'marketing_products.length', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
enableSorting: false,
cell: (props) => { cell: (props) => {
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) {
@@ -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,
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={{
@@ -1294,7 +777,6 @@ const MarketingTable = () => {
ref={filterModal.ref} ref={filterModal.ref}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/> />
</> </>
); );
@@ -246,7 +246,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 +261,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) => {
@@ -5,14 +5,10 @@ export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(), product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(), status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(), customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
}); });
export type MarketingFilterFormValues = { export type MarketingFilterFormValues = {
product_ids: OptionType<number>[]; product_ids: OptionType<number>[];
status: OptionType<string> | null; status: OptionType<string> | null;
customer: OptionType<number> | null; customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}; };
@@ -71,14 +71,14 @@ export const DeliveryOrderSchema: Yup.ObjectSchema<DeliveryOrderSchemaType> =
.required('Pengiriman wajib diisi!') .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 });
}); });
@@ -123,17 +123,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,11 +144,9 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => { ): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => { const data = delivery.deliveries.map((item) => {
const salesOrder = const salesOrder = salesOrders.find(
salesOrders.find((so) => so.id === item.marketing_product_id) ?? (so) => so.product_warehouse.id === item.product_warehouse.id
salesOrders.find( );
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const warehouseOption = { const warehouseOption = {
value: item.product_warehouse.warehouse.id, value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name, label: item.product_warehouse.warehouse.name,
@@ -191,20 +180,11 @@ 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: salesOrder?.id,
marketing_type: salesOrder?.marketing_type marketing_type: salesOrder?.marketing_type
? { ? {
value: value: salesOrder?.marketing_type,
salesOrder?.marketing_type === 'AYAM' || label: formatTitleCase(salesOrder?.marketing_type),
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM,AYAM_PULLET'
: salesOrder?.marketing_type,
label: formatTitleCase(
salesOrder?.marketing_type === 'AYAM' ||
salesOrder?.marketing_type === 'AYAM_PULLET'
? 'AYAM'
: salesOrder?.marketing_type
),
} }
: null, : null,
convertion_unit: salesOrder?.convertion_unit convertion_unit: salesOrder?.convertion_unit
@@ -214,7 +194,7 @@ export const DeliveryProductToFieldValues = (
} }
: null, : null,
marketing_product: { marketing_product: {
id: item.marketing_product_id ?? salesOrder?.id, id: salesOrder?.id,
vehicle_number: item.vehicle_number, vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id, warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption, warehouse: warehouseOption,
@@ -146,6 +146,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(() => {
@@ -181,19 +190,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,
@@ -401,10 +403,7 @@ const DeliveryOrderProductForm = ({
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
if ( if (!Boolean(initialValues.qty)) {
!Boolean(initialValues.qty) &&
!Boolean(initialValues.marketing_product_id)
) {
handleResetForm(); handleResetForm();
} else { } else {
setFormikValues({ setFormikValues({
@@ -414,7 +413,7 @@ const DeliveryOrderProductForm = ({
}); });
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?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
} as OptionType); } as OptionType);
} }
@@ -431,8 +430,7 @@ 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 ?? ''
); );
}, },
} }
@@ -805,8 +803,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 +816,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
: '')
: '' : ''
} }
/> />
@@ -252,11 +252,6 @@ const SalesOrderProductForm = ({
setSelectedProductWarehouse(productWarehouse || null); setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null); formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity); formik.setFieldValue('qty', productWarehouse?.quantity);
if (productWarehouse?.quantity) {
handleFieldChange('qty', productWarehouse?.quantity);
}
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || ''); formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if ( if (
productWarehouse?.week !== undefined && productWarehouse?.week !== undefined &&
@@ -124,7 +124,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>
@@ -273,7 +273,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(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}` ? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
: '-'} : '-'}
</td> </td>
@@ -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 () => {
@@ -445,13 +475,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 +494,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 +510,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,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;
}; };
@@ -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>
@@ -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,
@@ -147,6 +148,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 +174,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 +185,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 =====
@@ -211,7 +206,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
); );
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,
@@ -261,18 +257,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 +266,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 +307,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 +350,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 +425,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'
@@ -574,7 +554,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 +776,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 +972,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>
@@ -1324,14 +1349,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>
@@ -1,6 +1,12 @@
'use client'; 'use client';
import React, { useCallback, useState, useMemo, useEffect } from 'react'; import React, {
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table'; import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -12,7 +18,6 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput'; import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
@@ -34,11 +39,13 @@ import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording'; import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils'; import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production'; import { RecordingApi } from '@/services/api/production';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
@@ -68,26 +75,6 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[normalizedStatus] || 'neutral'; return statusBadgeColorMap[normalizedStatus] || 'neutral';
}; };
const isRecordingApproved = (recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
};
// ===== FILTER HELPERS =====
const recordingApprovalStatusOptions: OptionType<string>[] = [
{ value: 'CREATED', label: 'Pengajuan' },
{ value: 'UPDATED', label: 'Diperbarui' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const projectFlockCategoryOptions: OptionType<string>[] = [
{ value: 'GROWING', label: 'Growing' },
{ value: 'LAYING', label: 'Laying' },
];
const RowOptionsMenu = ({ const RowOptionsMenu = ({
popoverPosition = 'bottom', popoverPosition = 'bottom',
props, props,
@@ -279,111 +266,80 @@ const RowOptionsMenu = ({
}; };
const RecordingTable = () => { const RecordingTable = () => {
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<number> | null;
locationFilter: OptionType<number> | null;
projectFlockFilter: OptionType<number> | null;
kandangFilter: OptionType<number> | null;
projectFlockKandangFilter: number | null;
approvalStatusFilter: OptionType<string> | null;
projectFlockCategoryFilter: OptionType<string> | null;
}>({
initial: { initial: {
search: '', search: '',
areaFilter: null, areaFilter: '',
locationFilter: null, locationFilter: '',
projectFlockFilter: null, kandangFilter: '',
kandangFilter: null, projectFlockKandangFilter: '',
projectFlockKandangFilter: null,
approvalStatusFilter: null,
projectFlockCategoryFilter: null,
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
search: 'search', search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
projectFlockFilter: 'project_flock_id',
kandangFilter: 'kandang_id', kandangFilter: 'kandang_id',
projectFlockKandangFilter: 'project_flock_kandang_id', projectFlockKandangFilter: 'project_flock_kandang_id',
approvalStatusFilter: 'approval_status',
projectFlockCategoryFilter: 'project_flock_category',
}, },
persist: true,
storeName: 'recording-table',
}); });
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
// ===== FILTER MODAL STATE ===== // ===== FILTER MODAL STATE =====
const filterModal = useModal(); const filterModal = useModal();
// ===== FILTER STATE =====
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
const [filterProjectFlock, setFilterProjectFlock] =
useState<OptionType | null>(null);
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
undefined
);
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<RecordingFilterType>({ const formik = useFormik<RecordingFilterType>({
initialValues: { initialValues: {
area_id: tableFilterState.areaFilter, area_id: null,
location_id: tableFilterState.locationFilter, location_id: null,
project_flock_id: tableFilterState.projectFlockFilter, kandang_id: null,
kandang_id: tableFilterState.kandangFilter, project_flock_kandang_id: null,
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
approval_status: tableFilterState.approvalStatusFilter,
project_flock_category: tableFilterState.projectFlockCategoryFilter,
}, },
validationSchema: RecordingFilterSchema, validationSchema: RecordingFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id, true); updateFilter('areaFilter', values.area_id || '');
updateFilter('locationFilter', values.location_id, true); updateFilter('locationFilter', values.location_id || '');
updateFilter('projectFlockFilter', values.project_flock_id, true); updateFilter('kandangFilter', values.kandang_id || '');
updateFilter('kandangFilter', values.kandang_id, true);
updateFilter( updateFilter(
'projectFlockKandangFilter', 'projectFlockKandangFilter',
values.project_flock_kandang_id, values.project_flock_kandang_id || ''
true
);
updateFilter('approvalStatusFilter', values.approval_status, true);
updateFilter(
'projectFlockCategoryFilter',
values.project_flock_category,
true
); );
filterModal.closeModal(); filterModal.closeModal();
setSubmitting(false); setSubmitting(false);
}, },
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
updateFilter('projectFlockKandangFilter', '');
},
}); });
const formikResetHandler = () => {
updateFilter('areaFilter', null, true);
updateFilter('locationFilter', null, true);
updateFilter('projectFlockFilter', null, true);
updateFilter('kandangFilter', null, true);
updateFilter('projectFlockKandangFilter', null, true);
updateFilter('approvalStatusFilter', null, true);
updateFilter('projectFlockCategoryFilter', null, true);
formik.resetForm({
values: {
area_id: null,
location_id: null,
project_flock_id: null,
kandang_id: null,
project_flock_kandang_id: null,
approval_status: null,
project_flock_category: null,
},
});
filterModal.closeModal();
};
const { project_flock_id, kandang_id } = formik.values;
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) => const selectedRowIds = Object.keys(rowSelection).map((item) =>
@@ -399,14 +355,10 @@ const RecordingTable = () => {
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false); useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const singleDeleteModal = useModal(); const singleDeleteModal = useModal();
const approveModal = useModal(); const approveModal = useModal();
const rejectModal = useModal(); const rejectModal = useModal();
const exportProgressInputModal = useModal();
const { const {
data: recordings, data: recordings,
@@ -418,6 +370,13 @@ const RecordingTable = () => {
); );
// ===== LOCATION, AREA, KANDANG OPTIONS ===== // ===== LOCATION, AREA, KANDANG OPTIONS =====
const locationParams = useMemo(() => {
if (filterLocationAreaId) {
return { area_id: filterLocationAreaId };
}
return undefined;
}, [filterLocationAreaId]);
const { const {
setInputValue: setLocationInputValue, setInputValue: setLocationInputValue,
options: locationOptions, options: locationOptions,
@@ -428,9 +387,7 @@ const RecordingTable = () => {
'id', 'id',
'name', 'name',
'search', 'search',
{ locationParams
area_id: String(formik.values.area_id?.value),
}
); );
const { const {
@@ -445,6 +402,13 @@ const RecordingTable = () => {
'search' 'search'
); );
const projectFlockParams = useMemo(() => {
if (filterProjectFlockLocationId) {
return { location_id: filterProjectFlockLocationId };
}
return undefined;
}, [filterProjectFlockLocationId]);
const { const {
setInputValue: setProjectFlockInputValue, setInputValue: setProjectFlockInputValue,
options: projectFlockOptions, options: projectFlockOptions,
@@ -456,41 +420,34 @@ const RecordingTable = () => {
'id', 'id',
'flock_name', 'flock_name',
'search', 'search',
{ projectFlockParams
location_id: String(formik.values.location_id?.value),
}
); );
const kandangOptions = useMemo(() => { const kandangOptions = useMemo(() => {
if (!project_flock_id || !projectFlocksRawData) return []; if (!filterProjectFlock || !projectFlocksRawData) return [];
if (!isResponseSuccess(projectFlocksRawData)) return []; if (!isResponseSuccess(projectFlocksRawData)) return [];
const data = projectFlocksRawData.data as ProjectFlock[]; const data = projectFlocksRawData.data as ProjectFlock[];
const selectedProjectFlockData = data.find((pf) => const selectedProjectFlockData = data.find(
pf.id === formik.values.project_flock_id?.value (pf) => pf.id === filterProjectFlock.value
? Number(formik.values.project_flock_id.value)
: 0
); );
if (!selectedProjectFlockData?.kandangs) return []; if (!selectedProjectFlockData?.kandangs) return [];
return selectedProjectFlockData.kandangs.map((k) => ({ return selectedProjectFlockData.kandangs.map((k) => ({
value: k.id, value: k.id,
label: k.name || '', label: k.name || '',
})); }));
}, [project_flock_id, projectFlocksRawData]); }, [filterProjectFlock, projectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP ===== // ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => { const projectFlockKandangLookupUrl = useMemo(() => {
if (!project_flock_id?.value || !kandang_id?.value) return null; if (!filterProjectFlock || !filterKandang) return null;
const params = new URLSearchParams({ const params = new URLSearchParams({
project_flock_id: project_flock_id.value.toString(), project_flock_id: filterProjectFlock.value.toString(),
kandang_id: kandang_id.value.toString(), kandang_id: filterKandang.value.toString(),
}); });
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`; return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [project_flock_id, kandang_id]); }, [filterProjectFlock, filterKandang]);
const { data: projectFlockKandangLookupData } = useSWR( const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl, projectFlockKandangLookupUrl,
@@ -512,45 +469,118 @@ const RecordingTable = () => {
? projectFlockKandangLookupData.data ? projectFlockKandangLookupData.data
: undefined; : undefined;
const formikRef = useRef(formik);
useEffect(() => {
formikRef.current = formik;
});
useEffect(() => { useEffect(() => {
if (projectFlockKandangLookup?.id) { if (projectFlockKandangLookup?.id) {
const pfkId = String(projectFlockKandangLookup.id); const pfkId = String(projectFlockKandangLookup.id);
formik.setFieldValue('project_flock_kandang_id', pfkId); setFilterProjectFlockKandangId(projectFlockKandangLookup.id);
formikRef.current.setFieldValue('project_flock_kandang_id', pfkId);
} else { } else {
formik.setFieldValue('project_flock_kandang_id', null); setFilterProjectFlockKandangId(undefined);
formikRef.current.setFieldValue('project_flock_kandang_id', null);
} }
}, [projectFlockKandangLookup]); }, [projectFlockKandangLookup]);
// ===== FILTER HANDLERS ===== // ===== FILTER HANDLERS =====
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => { const handleFilterAreaChange = useCallback(
formik.setFieldValue('area_id', val); (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('location_id', null); const area = val as OptionType | null;
formik.setFieldValue('project_flock_id', null); const areaId = area?.value ? String(area.value) : null;
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterLocationChange = ( formik.setFieldValue('area_id', areaId);
val: OptionType | OptionType[] | null formik.setFieldValue('location_id', null);
) => { formik.setFieldValue('kandang_id', null);
formik.setFieldValue('location_id', val); formik.setFieldValue('project_flock_kandang_id', null);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterProjectFlockChange = ( setFilterArea(area);
val: OptionType | OptionType[] | null setFilterLocation(null);
) => { setFilterProjectFlock(null);
formik.setFieldValue('project_flock_id', val); setFilterKandang(null);
formik.setFieldValue('kandang_id', null); setFilterLocationAreaId(areaId || '');
formik.setFieldValue('project_flock_kandang_id', null); setFilterProjectFlockLocationId('');
}; },
[formik]
);
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => { const handleFilterLocationChange = useCallback(
formik.setFieldValue('kandang_id', val); (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('project_flock_kandang_id', null); const location = val as OptionType | null;
}; const locationId = location?.value ? String(location.value) : null;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterLocation(location);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockLocationId(locationId || '');
},
[formik]
);
const handleFilterProjectFlockChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const projectFlock = val as OptionType | null;
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterProjectFlock(projectFlock);
setFilterKandang(null);
},
[formik]
);
const handleFilterKandangChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const kandang = val as OptionType | null;
const kandangId = kandang?.value ? String(kandang.value) : null;
formik.setFieldValue('kandang_id', kandangId);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterKandang(kandang);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockIdValue = useMemo(() => {
if (!filterProjectFlock) return null;
return filterProjectFlock;
}, [filterProjectFlock]);
const kandangIdValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
);
}, [formik.values.kandang_id, kandangOptions]);
// ===== HANDLE FILTER MODAL OPEN ===== // ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => { const handleFilterModalOpen = () => {
@@ -558,9 +588,25 @@ const RecordingTable = () => {
formik.validateForm(); formik.validateForm();
}; };
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => { const isRecordingApproved = useCallback((recording: Recording): boolean => {
updateFilter('search', e.target.value, true); return (
}; recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
}, []);
useEffect(() => {
setTableState('recording-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const singleDeleteHandler = async () => { const singleDeleteHandler = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
@@ -652,60 +698,6 @@ const RecordingTable = () => {
setIsLoadingExportingToExcel(false); setIsLoadingExportingToExcel(false);
}; };
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressStartDate(e.target.value);
},
[]
);
const exportProgressEndDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressEndDate(e.target.value);
},
[]
);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await RecordingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
useEffect(() => { useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) { if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {}; const newSelection: Record<string, boolean> = {};
@@ -866,8 +858,7 @@ const RecordingTable = () => {
<> <>
<span> <span>
{props.row.original.day} (Minggu ke- {props.row.original.day} (Minggu ke-
{props.row.original.week} hari ke- {props.row.original.project_flock.production_standart.week})
{props.row.original.excess_days})
</span> </span>
</> </>
); );
@@ -1113,7 +1104,7 @@ const RecordingTable = () => {
return ( return (
<div className='text-center'> <div className='text-center'>
{value !== null && value !== undefined {value !== null && value !== undefined
? `${value.toFixed(2)} butir` ? `${value.toFixed(2)}%`
: '-'} : '-'}
</div> </div>
); );
@@ -1129,7 +1120,7 @@ const RecordingTable = () => {
return ( return (
<div className='text-center text-gray-600'> <div className='text-center text-gray-600'>
{value !== null && value !== undefined {value !== null && value !== undefined
? `${value.toFixed(2)} btr` ? `${value.toFixed(2)}%`
: '-'} : '-'}
</div> </div>
); );
@@ -1377,16 +1368,6 @@ const RecordingTable = () => {
<Icon icon='heroicons:table-cells' width={20} height={20} /> <Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel Export to Excel
</Button> </Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown> </Dropdown>
</div> </div>
</div> </div>
@@ -1465,13 +1446,13 @@ const RecordingTable = () => {
<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_id} value={areaIdValue}
onChange={handleFilterAreaChange} onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue} onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions} isLoading={isLoadingAreaOptions}
@@ -1484,13 +1465,13 @@ const RecordingTable = () => {
label='Lokasi' label='Lokasi'
placeholder='Pilih Lokasi' placeholder='Pilih Lokasi'
options={locationOptions} options={locationOptions}
value={formik.values.location_id} value={locationIdValue}
onChange={handleFilterLocationChange} onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue} onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions} isLoading={isLoadingLocationOptions}
isClearable isClearable
onMenuScrollToBottom={loadMoreLocations} onMenuScrollToBottom={loadMoreLocations}
isDisabled={!formik.values.area_id?.value} isDisabled={!filterArea}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1498,13 +1479,13 @@ const RecordingTable = () => {
label='Project Flock' label='Project Flock'
placeholder='Pilih Project Flock' placeholder='Pilih Project Flock'
options={projectFlockOptions} options={projectFlockOptions}
value={formik.values.project_flock_id} value={projectFlockIdValue}
onChange={handleFilterProjectFlockChange} onChange={handleFilterProjectFlockChange}
onInputChange={setProjectFlockInputValue} onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlocks} isLoading={isLoadingProjectFlocks}
isClearable isClearable
onMenuScrollToBottom={loadMoreProjectFlocks} onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id?.value} isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
@@ -1512,35 +1493,11 @@ const RecordingTable = () => {
label='Kandang' label='Kandang'
placeholder='Pilih Kandang' placeholder='Pilih Kandang'
options={kandangOptions} options={kandangOptions}
value={formik.values.kandang_id} value={kandangIdValue}
onChange={handleFilterKandangChange} onChange={handleFilterKandangChange}
isLoading={!formik.values.project_flock_id?.value} isLoading={!filterProjectFlock}
isClearable
isDisabled={!formik.values.project_flock_id?.value}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={projectFlockCategoryOptions}
value={formik.values.project_flock_category}
onChange={(val) => {
formik.setFieldValue('project_flock_category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Approval'
placeholder='Pilih Status Approval'
options={recordingApprovalStatusOptions}
value={formik.values.approval_status}
onChange={(val) => {
formik.setFieldValue('approval_status', val);
}}
isClearable isClearable
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
/> />
</div> </div>
@@ -1548,16 +1505,30 @@ const RecordingTable = () => {
{/* 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();
setFilterArea(null);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId('');
setFilterProjectFlockLocationId('');
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 || formik.isSubmitting} disabled={
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -1580,76 +1551,6 @@ const RecordingTable = () => {
}} }}
/> />
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={approveModal.ref} ref={approveModal.ref}
type='success' type='success'
@@ -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,19 +29,11 @@ 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; source_product_warehouse_id?: number;
qty?: number | string; qty?: number | string;
}[]; }[];
@@ -51,48 +41,34 @@ type RecordingGrowingFormSchemaType = {
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; 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!') .moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
@@ -100,12 +76,9 @@ const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
}); });
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() source_product_warehouse_id: Yup.number()
.optional() .optional()
.typeError('Gudang sumber harus berupa angka!'), .typeError('Gudang sumber harus berupa angka!'),
@@ -115,12 +88,9 @@ const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
}); });
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 +248,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 +263,13 @@ 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, 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 +281,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: '',
}, },
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { useMemo, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -31,14 +31,12 @@ import {
RecordingApi, RecordingApi,
ProjectFlockApi, ProjectFlockApi,
} from '@/services/api/production'; } from '@/services/api/production';
import { ProductionStandardApi, ProductApi } from '@/services/api/master-data'; import { ProductionStandardApi } from '@/services/api/master-data';
import { import {
ProductionStandard, ProductionStandard,
StandardDetails, StandardDetails,
} from '@/types/api/master-data/production-standard'; } from '@/types/api/master-data/production-standard';
import { Product } from '@/types/api/master-data/product';
import { LocationApi } from '@/services/api/master-data'; import { LocationApi } from '@/services/api/master-data';
import { SystemSettingsApi } from '@/services/api/system-settings';
import { ProductWarehouseApi } from '@/services/api/inventory'; import { ProductWarehouseApi } from '@/services/api/inventory';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
@@ -501,20 +499,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
type, type,
]); ]);
// ===== MIGRATION MODE =====
const { data: systemSettingsResponse } = useSWR(
SystemSettingsApi.basePath,
SystemSettingsApi.getAllFetcher
);
const isMigrationMode = useMemo(() => {
if (!isResponseSuccess(systemSettingsResponse)) return false;
const setting = systemSettingsResponse.data.find(
(s) => s.key === 'allow_negative_pakan_ovk'
);
return setting?.value === 'true';
}, [systemSettingsResponse]);
// ===== PAYLOAD CREATION HELPERS ===== // ===== PAYLOAD CREATION HELPERS =====
const createGrowingPayload = useCallback( const createGrowingPayload = useCallback(
(values: RecordingGrowingFormValues) => { (values: RecordingGrowingFormValues) => {
@@ -522,7 +506,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
? values.depletions ? values.depletions
?.filter((d) => d.product_warehouse_id && d.qty) ?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({ .map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0, product_warehouse_id: depletion.product_warehouse_id!,
...(depletion.source_product_warehouse_id && { ...(depletion.source_product_warehouse_id && {
source_product_warehouse_id: source_product_warehouse_id:
depletion.source_product_warehouse_id, depletion.source_product_warehouse_id,
@@ -533,13 +517,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock const stocks = recordingRestriction.canEditStock
? (values.stocks ?? []) ? (values.stocks ?? [])
.filter((s) => s.product_warehouse_id?.value && s.qty) .filter((s) => s.product_warehouse_id && s.qty)
.map((stock) => ({ .map((stock) => ({
// In migration mode, product_warehouse_id field holds product.id; product_warehouse_id: stock.product_warehouse_id,
// send it as product_id so the backend auto-creates the warehouse entry.
...(isMigrationMode
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0, qty: Number(stock.qty) || 0,
})) }))
: []; : [];
@@ -551,19 +531,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(depletions.length > 0 && { depletions }), ...(depletions.length > 0 && { depletions }),
}; };
}, },
[ [recordingRestriction.canEditStock, recordingRestriction.canEditDepletion]
isMigrationMode,
recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion,
]
); );
const createLayingPayload = useCallback( const createLayingPayload = useCallback(
(values: RecordingLayingFormValues) => { (values: RecordingLayingFormValues) => {
const depletions = values.depletions const depletions = values.depletions
?.filter((d) => d.product_warehouse_id?.value && d.qty) ?.filter((d) => d.product_warehouse_id && d.qty)
.map((depletion) => ({ .map((depletion) => ({
product_warehouse_id: depletion.product_warehouse_id?.value ?? 0, product_warehouse_id: depletion.product_warehouse_id!,
...(depletion.source_product_warehouse_id && { ...(depletion.source_product_warehouse_id && {
source_product_warehouse_id: depletion.source_product_warehouse_id, source_product_warehouse_id: depletion.source_product_warehouse_id,
}), }),
@@ -573,7 +549,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const eggs = values.eggs const eggs = values.eggs
?.filter((e) => e.product_warehouse_id && e.qty && e.weight) ?.filter((e) => e.product_warehouse_id && e.qty && e.weight)
.map((egg) => ({ .map((egg) => ({
product_warehouse_id: egg.product_warehouse_id?.value ?? 0, product_warehouse_id: egg.product_warehouse_id!,
qty: Number(egg.qty) || 0, qty: Number(egg.qty) || 0,
weight: weight:
typeof egg.weight === 'number' typeof egg.weight === 'number'
@@ -583,11 +559,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const stocks = recordingRestriction.canEditStock const stocks = recordingRestriction.canEditStock
? values.stocks ? values.stocks
.filter((s) => s.product_warehouse_id?.value && s.qty) .filter((s) => s.product_warehouse_id && s.qty)
.map((stock) => ({ .map((stock) => ({
...(isMigrationMode product_warehouse_id: stock.product_warehouse_id,
? { product_id: stock.product_warehouse_id?.value }
: { product_warehouse_id: stock.product_warehouse_id?.value }),
qty: Number(stock.qty) || 0, qty: Number(stock.qty) || 0,
})) }))
: []; : [];
@@ -600,7 +574,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(eggs && eggs.length > 0 && { eggs }), ...(eggs && eggs.length > 0 && { eggs }),
}; };
}, },
[isMigrationMode, recordingRestriction.canEditStock] [recordingRestriction.canEditStock]
); );
const isRecordingEditable = useCallback((recording?: Recording) => { const isRecordingEditable = useCallback((recording?: Recording) => {
@@ -629,13 +603,11 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return true; return true;
}, []); }, []);
// When migration mode ON: fetch all master PAKAN/OVK products (no warehouse entry needed).
// When migration mode OFF: fetch from product-warehouses as usual.
const { const {
setInputValue: setStockProductInputValue, setInputValue: setStockProductInputValue,
rawData: stockProductsPW, rawData: stockProducts,
isLoadingOptions: isLoadingStockProductsPW, isLoadingOptions: isLoadingStockProducts,
loadMore: loadMoreStockProductsPW, loadMore: loadMoreStockProducts,
} = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', { } = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', 'search', {
flags: 'PAKAN,OVK', flags: 'PAKAN,OVK',
limit: '100', limit: '100',
@@ -644,29 +616,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}), ...(selectedKandangId ? { kandang_id: selectedKandangId.toString() } : {}),
}); });
const {
setInputValue: setStockMasterInputValue,
rawData: stockProductsMaster,
isLoadingOptions: isLoadingStockProductsMaster,
loadMore: loadMoreStockProductsMaster,
} = useSelect(
isMigrationMode ? ProductApi.basePath : null,
'id',
'name',
'search',
{ flags: 'PAKAN,OVK', limit: '100' }
);
const isLoadingStockProducts = isMigrationMode
? isLoadingStockProductsMaster
: isLoadingStockProductsPW;
const loadMoreStockProducts = isMigrationMode
? loadMoreStockProductsMaster
: loadMoreStockProductsPW;
const setStockInputValue = isMigrationMode
? setStockMasterInputValue
: setStockProductInputValue;
const { const {
rawData: depletionProductsData, rawData: depletionProductsData,
isLoadingOptions: isLoadingDepletionProducts, isLoadingOptions: isLoadingDepletionProducts,
@@ -1050,9 +999,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
useEffect(() => { useEffect(() => {
const items: Array<ProductWarehouse | null | undefined> = []; const items: Array<ProductWarehouse | null | undefined> = [];
if (!isMigrationMode && isResponseSuccess(stockProductsPW)) { if (isResponseSuccess(stockProducts)) {
items.push( items.push(
...((stockProductsPW.data as unknown as ProductWarehouse[]) ?? []) ...((stockProducts.data as unknown as ProductWarehouse[]) ?? [])
); );
} }
@@ -1086,8 +1035,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
mergeKnownProductWarehouses(items); mergeKnownProductWarehouses(items);
}, [ }, [
isMigrationMode, stockProducts,
stockProductsPW,
depletionProductsData, depletionProductsData,
eggProductsData, eggProductsData,
initialValues, initialValues,
@@ -1118,20 +1066,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
); );
const unifiedStockProducts = useMemo(() => { const unifiedStockProducts = useMemo(() => {
if (isMigrationMode) { const options = isResponseSuccess(stockProducts)
// In migration mode, show all master PAKAN/OVK products (no warehouse context).
// value = product.id; submission will send product_id to the backend.
const options: OptionType[] = isResponseSuccess(stockProductsMaster)
? (stockProductsMaster.data as unknown as Product[])
.map((p) => ({ value: p.id, label: p.name }))
.sort((a, b) => a.label.localeCompare(b.label))
: [];
return options;
}
const options = isResponseSuccess(stockProductsPW)
? buildProductWarehouseOptions( ? buildProductWarehouseOptions(
stockProductsPW.data as unknown as ProductWarehouse[] stockProducts.data as unknown as ProductWarehouse[]
) )
: []; : [];
@@ -1148,9 +1085,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return options; return options;
}, [ }, [
isMigrationMode, stockProducts,
stockProductsMaster,
stockProductsPW,
buildProductWarehouseOptions, buildProductWarehouseOptions,
initialValues, initialValues,
type, type,
@@ -1269,22 +1204,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}; };
} }
// In migration mode (edit), the dropdown options use product.id as their value,
// but the API returns product_warehouse_id (PW entity ID). Remap so the dropdown
// can match the correct option. The product ID is available on the nested
// product_warehouse object returned by the API.
if (isMigrationMode && type === 'edit' && initialValues?.stocks?.length) {
baseValues.stocks = initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}));
}
if (!recordingRestriction.canEditStock) { if (!recordingRestriction.canEditStock) {
baseValues.stocks = []; baseValues.stocks = [];
} }
@@ -1305,7 +1224,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
selectedKandang, selectedKandang,
recordingRestriction.canEditStock, recordingRestriction.canEditStock,
recordingRestriction.canEditDepletion, recordingRestriction.canEditDepletion,
isMigrationMode,
]); ]);
const formik = useFormik< const formik = useFormik<
@@ -1417,35 +1335,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, },
}); });
// SWR timing fix: formik initializes before system-settings load, so isMigrationMode
// starts false. When it flips true, formikInitialValues recomputes but enableReinitialize
// is false, so formik won't pick it up. Push the corrected stock values once, and only
// once — the ref prevents re-firing if something causes isMigrationMode to re-evaluate.
const migrationEditMappingApplied = useRef(false);
useEffect(() => {
if (
type !== 'edit' ||
!isMigrationMode ||
!initialValues?.stocks?.length ||
migrationEditMappingApplied.current
)
return;
migrationEditMappingApplied.current = true;
formik.setFieldValue(
'stocks',
initialValues.stocks.map((stock) => ({
product_warehouse_id: {
value: Number(
stock.product_warehouse?.product_id ?? stock.product_warehouse_id
),
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
qty: stock.usage_amount ?? '',
}))
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMigrationMode]);
// ===== HELPER FUNCTIONS ===== // ===== HELPER FUNCTIONS =====
const { setFieldValue } = formik; const { setFieldValue } = formik;
@@ -1462,7 +1351,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(stockIdx: number) => { (stockIdx: number) => {
if ((type as 'add' | 'edit' | 'detail') === 'detail') return null; if ((type as 'add' | 'edit' | 'detail') === 'detail') return null;
const stock = formik.values.stocks?.[stockIdx]; const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id?.value) return null; if (!stock || !stock.product_warehouse_id) return null;
return null; return null;
}, },
[formik.values.stocks, type] [formik.values.stocks, type]
@@ -1472,7 +1361,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
(productWarehouseId: number) => { (productWarehouseId: number) => {
if ((type === 'edit' || type === 'detail') && initialValues?.stocks) { if ((type === 'edit' || type === 'detail') && initialValues?.stocks) {
const existingStock = initialValues.stocks.find( const existingStock = initialValues.stocks.find(
(s) => Number(s.product_warehouse_id) === Number(productWarehouseId) (s) => s.product_warehouse_id === productWarehouseId
) as RecordingStock | undefined; ) as RecordingStock | undefined;
if (existingStock) { if (existingStock) {
return { return {
@@ -1492,25 +1381,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const getStockUsageAdornment = useCallback( const getStockUsageAdornment = useCallback(
(stockIdx: number) => { (stockIdx: number) => {
const stock = formik.values.stocks?.[stockIdx]; const stock = formik.values.stocks?.[stockIdx];
if (!stock || !stock.product_warehouse_id?.value) return null; if (!stock || !stock.product_warehouse_id) return null;
const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail'; const isDetail = (type as 'add' | 'edit' | 'detail') === 'detail';
const availableStock = getAvailableStock( const availableStock = getAvailableStock(stock.product_warehouse_id);
stock.product_warehouse_id.value
);
const requestedUsage = Number(stock.qty) || 0; const requestedUsage = Number(stock.qty) || 0;
const remainingStock = availableStock - requestedUsage; const remainingStock = availableStock - requestedUsage;
const { pendingQty } = getStockPendingInfo( const { pendingQty } = getStockPendingInfo(stock.product_warehouse_id);
stock.product_warehouse_id.value
);
if (isDetail) { if (isDetail) {
if (pendingQty > 0) { if (pendingQty > 0) {
return ( return (
<span className='text-sm text-gray-600 whitespace-nowrap'> <span className='text-sm text-gray-600 whitespace-nowrap'>
(tersedia: {formatNumber(availableStock)} | pending:{' '} (tersedia: {formatNumber(requestedUsage)} | pending:{' '}
<span className='text-error'>{formatNumber(pendingQty)}</span> | <span className='text-error'>{formatNumber(pendingQty)}</span> |
pakai: {formatNumber(requestedUsage)}) pakai: {formatNumber(requestedUsage + pendingQty)})
</span> </span>
); );
} }
@@ -1609,10 +1494,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return ( return (
idx !== currentIdx && idx !== currentIdx &&
s.product_warehouse_id && s.product_warehouse_id &&
s.product_warehouse_id.value !== 0 s.product_warehouse_id !== 0
); );
}) })
.map((s) => s.product_warehouse_id?.value) || []; .map((s) => s.product_warehouse_id) || [];
return unifiedStockProducts.filter( return unifiedStockProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value)) (opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1629,10 +1514,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return ( return (
idx !== currentIdx && idx !== currentIdx &&
d.product_warehouse_id && d.product_warehouse_id &&
d.product_warehouse_id.value !== 0 d.product_warehouse_id !== 0
); );
}) })
.map((d) => d.product_warehouse_id?.value) || []; .map((d) => d.product_warehouse_id) || [];
return depletionProducts.filter( return depletionProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value)) (opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1649,10 +1534,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
return ( return (
idx !== currentIdx && idx !== currentIdx &&
e.product_warehouse_id && e.product_warehouse_id &&
e.product_warehouse_id.value !== 0 e.product_warehouse_id !== 0
); );
}) })
.map((e) => e.product_warehouse_id?.value) || []; .map((e) => e.product_warehouse_id) || [];
return eggProducts.filter( return eggProducts.filter(
(opt) => !selectedProductIds.includes(Number(opt.value)) (opt) => !selectedProductIds.includes(Number(opt.value))
@@ -1698,9 +1583,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
isError: touchedField && Boolean(errorField?.[column]), isError: touchedField && Boolean(errorField?.[column]),
errorMessage: errorMessage:
touchedField && errorField?.[column] touchedField && errorField?.[column]
? errorField[column] instanceof Object ? (errorField[column] as string)
? (errorField[column] as OptionType)?.label
: (errorField[column] as string)
: '', : '',
}; };
}; };
@@ -1731,14 +1614,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false); formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [ formik.setFieldValue('stocks', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
formik.setFieldTouched('depletions', false, false); formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [ formik.setFieldValue('depletions', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
@@ -1746,7 +1629,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false); formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [ formik.setFieldValue('eggs', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
weight: '', weight: '',
}, },
@@ -1795,14 +1678,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false); formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [ formik.setFieldValue('stocks', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
formik.setFieldTouched('depletions', false, false); formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [ formik.setFieldValue('depletions', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
@@ -1810,7 +1693,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false); formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [ formik.setFieldValue('eggs', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
weight: '', weight: '',
}, },
@@ -1848,14 +1731,14 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('stocks', false, false); formik.setFieldTouched('stocks', false, false);
formik.setFieldValue('stocks', [ formik.setFieldValue('stocks', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
formik.setFieldTouched('depletions', false, false); formik.setFieldTouched('depletions', false, false);
formik.setFieldValue('depletions', [ formik.setFieldValue('depletions', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]); ]);
@@ -1863,7 +1746,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
formik.setFieldTouched('eggs', false, false); formik.setFieldTouched('eggs', false, false);
formik.setFieldValue('eggs', [ formik.setFieldValue('eggs', [
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
weight: '', weight: '',
}, },
@@ -2076,7 +1959,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newStocks = [ const newStocks = [
...(formik.values.stocks || []), ...(formik.values.stocks || []),
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]; ];
@@ -2108,7 +1991,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newDepletions = [ const newDepletions = [
...(formik.values.depletions || []), ...(formik.values.depletions || []),
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]; ];
@@ -2142,7 +2025,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
const newEggs = [ const newEggs = [
...((formik.values as RecordingLayingFormValues).eggs || []), ...((formik.values as RecordingLayingFormValues).eggs || []),
{ {
product_warehouse_id: null, product_warehouse_id: 0,
qty: '', qty: '',
}, },
]; ];
@@ -2185,7 +2068,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') { if (isLayingCategory && (type as 'add' | 'edit' | 'detail') !== 'detail') {
const layingValues = formik.values as RecordingLayingFormValues; const layingValues = formik.values as RecordingLayingFormValues;
if (!layingValues.eggs || layingValues.eggs.length === 0) { if (!layingValues.eggs || layingValues.eggs.length === 0) {
setFieldValue('eggs', [{ product_warehouse_id: null, qty: '' }]); setFieldValue('eggs', [{ product_warehouse_id: 0, qty: '' }]);
} }
} }
}, [isLayingCategory, type, formik.values, setFieldValue]); }, [isLayingCategory, type, formik.values, setFieldValue]);
@@ -2907,15 +2790,20 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
<td> <td>
<SelectInput <SelectInput
required required
key={`stock-product-${idx}-${stock.product_warehouse_id?.value}`} key={`stock-product-${idx}-${stock.product_warehouse_id}`}
value={stock.product_warehouse_id} value={
onInputChange={setStockInputValue} unifiedStockProducts.find(
(product) =>
product.value === stock.product_warehouse_id
) || null
}
onInputChange={setStockProductInputValue}
onChange={(selectedOption) => { onChange={(selectedOption) => {
const option = const option =
selectedOption as OptionType | null; selectedOption as OptionType | null;
formik.setFieldValue( formik.setFieldValue(
`stocks.${idx}.product_warehouse_id`, `stocks.${idx}.product_warehouse_id`,
option option?.value || 0
); );
}} }}
options={getAvailableStockProductOptions(idx)} options={getAvailableStockProductOptions(idx)}
@@ -2951,9 +2839,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
} }
isClearable={type !== 'detail'} isClearable={type !== 'detail'}
inputPrefix={ inputPrefix={
stock.product_warehouse_id?.value stock.product_warehouse_id
? getProductFlagBadgeAdornment( ? getProductFlagBadgeAdornment(
stock.product_warehouse_id.value stock.product_warehouse_id
) )
: undefined : undefined
} }
@@ -2989,7 +2877,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={ inputSuffix={
stock.product_warehouse_id stock.product_warehouse_id
? getProductUomSuffix( ? getProductUomSuffix(
stock.product_warehouse_id.value, stock.product_warehouse_id,
'stock' 'stock'
) )
: null : null
@@ -3182,13 +3070,19 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
<td> <td>
<SelectInput <SelectInput
value={depletion.product_warehouse_id} value={
depletionProducts.find(
(product) =>
product.value ===
depletion.product_warehouse_id
) || null
}
onChange={(selectedOption) => { onChange={(selectedOption) => {
const option = const option =
selectedOption as OptionType | null; selectedOption as OptionType | null;
formik.setFieldValue( formik.setFieldValue(
`depletions.${idx}.product_warehouse_id`, `depletions.${idx}.product_warehouse_id`,
option option?.value || 0
); );
}} }}
options={getAvailableDepletionProductOptions(idx)} options={getAvailableDepletionProductOptions(idx)}
@@ -3251,7 +3145,7 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
inputSuffix={ inputSuffix={
depletion.product_warehouse_id depletion.product_warehouse_id
? getProductUomSuffix( ? getProductUomSuffix(
depletion.product_warehouse_id.value, depletion.product_warehouse_id,
'depletion' 'depletion'
) )
: null : null
@@ -3429,13 +3323,18 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
)} )}
<td> <td>
<SelectInput <SelectInput
value={egg.product_warehouse_id} value={
eggProducts.find(
(product) =>
product.value === egg.product_warehouse_id
) || null
}
onChange={(selectedOption) => { onChange={(selectedOption) => {
const option = const option =
selectedOption as OptionType | null; selectedOption as OptionType | null;
formik.setFieldValue( formik.setFieldValue(
`eggs.${idx}.product_warehouse_id`, `eggs.${idx}.product_warehouse_id`,
option option?.value || 0
); );
}} }}
options={getAvailableEggProductOptions(idx)} options={getAvailableEggProductOptions(idx)}
@@ -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) {
@@ -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,8 +223,6 @@ 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<
@@ -457,13 +455,13 @@ 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);
@@ -627,7 +625,6 @@ const TransferToLayingFormModal = () => {
> >
<div className='flex flex-row items-center gap-3'> <div className='flex flex-row items-center gap-3'>
<input <input
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
type='radio' type='radio'
name='flockSourceKandang' name='flockSourceKandang'
value={item.project_flock_kandang_id} value={item.project_flock_kandang_id}
@@ -640,14 +637,13 @@ const TransferToLayingFormModal = () => {
/> />
<label <label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
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 opacity-50': !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 && (
@@ -928,6 +902,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 && (
@@ -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';
@@ -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 () => {
@@ -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}
/> />
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react'; import { RefObject, useState, useEffect } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -9,61 +9,31 @@ import Modal from '@/components/Modal';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase'; import { PurchaseFilter } from '@/types/api/purchase/purchase';
import { AreaApi, LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProductCategory } from '@/types/api/master-data/product-category'; import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line'; import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
const filterByOptions: OptionType<string>[] = [
{ value: 'po_date', label: 'Tanggal PO' },
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'due_date', label: 'Tanggal Jatuh Tempo' },
{ value: 'created_at', label: 'Tanggal Dibuat' },
];
interface PurchaseFilterModalProps { interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void; onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void; onReset?: () => void;
} }
const PurchaseFilterModal = ({ const PurchaseFilterModal = ({
ref, ref,
initialValues,
onSubmit, onSubmit,
onReset, onReset,
}: PurchaseFilterModalProps) => { }: PurchaseFilterModalProps) => {
const closeModalHandler = useCallback(() => { const closeModalHandler = () => {
ref.current?.close(); ref.current?.close();
}, [ref]); };
// ===== DATE ERROR STATE ===== // ===== DATE ERROR STATE =====
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT ===== // ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => { useEffect(() => {
@@ -103,140 +73,32 @@ const PurchaseFilterModal = ({
'search' 'search'
); );
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<{ const formik = useFormik<{
poDate: string; poDate: string;
start_date: string;
end_date: string;
filterBy: OptionType<string> | undefined;
category: { label: string; value: number }[]; category: { label: string; value: number }[];
status: { label: string; value: string }[]; status: { label: string; value: string }[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({ }>({
// enableReinitialize: true, initialValues: {
initialValues: initialValues || {
poDate: '', poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [], category: [],
status: [], status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
}, },
onSubmit: async (values) => { onSubmit: async (values) => {
const formattedValues = { const formattedValues = {
...values, ...values,
category: values.category.map((item) => String(item.value)), category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)), status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
}; };
onSubmit?.(formattedValues); onSubmit?.(formattedValues);
closeModalHandler(); closeModalHandler();
}, },
onReset: () => { onReset: () => {
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.(); onReset?.();
closeModalHandler(); closeModalHandler();
}, },
}); });
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
const productCategoryChangeHandler = ( const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null val: OptionType | OptionType[] | null
) => { ) => {
@@ -247,86 +109,6 @@ const PurchaseFilterModal = ({
formik.setFieldValue('status', val); formik.setFieldValue('status', val);
}; };
const formikResetHandler = useCallback(() => {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
resetForm({
values: {
poDate: '',
start_date: '',
end_date: '',
filterBy: undefined,
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler, dateErrorShown]);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh sebelum tanggal mulai', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return ( return (
<Modal <Modal
ref={ref} ref={ref}
@@ -336,7 +118,7 @@ const PurchaseFilterModal = ({
> >
<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 */}
@@ -350,9 +132,7 @@ const PurchaseFilterModal = ({
type='button' type='button'
variant='ghost' variant='ghost'
color='none' color='none'
onClick={() => { onClick={closeModalHandler}
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content' className='p-0 text-base-content/50 hover:text-base-content'
> >
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
@@ -362,44 +142,6 @@ const PurchaseFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'> <div className='flex flex-col'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
/>
<DateInput <DateInput
label='PO Date' label='PO Date'
name='poDate' name='poDate'
@@ -430,108 +172,6 @@ const PurchaseFilterModal = ({
value: item.step_name, value: item.step_name,
}))} }))}
/> />
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
value={formik.values.supplier}
onChange={(val) =>
formik.setFieldValue(
'supplier',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
/>
<SelectInput
label='Area'
placeholder='Pilih Area'
value={formik.values.area}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('area', nextValue);
formik.setFieldValue('location', null);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedAreaId(
nextValue?.value ? String(nextValue.value) : ''
);
setSelectedLocationId('');
}}
options={areaOptions}
isLoading={isLoadingAreaOptions}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('location', nextValue);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(
nextValue?.value ? String(nextValue.value) : ''
);
}}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
value={formik.values.project_flock}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('project_flock', nextValue);
formik.setFieldValue('project_flock_kandang', null);
}}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={projectFlockKandangOptions}
isClearable
isDisabled={!formik.values.project_flock}
/>
</div> </div>
</div> </div>
@@ -547,9 +187,7 @@ const PurchaseFilterModal = ({
</Button> </Button>
<Button <Button
type='button' type='submit'
onClick={formikSubmitHandler}
disabled={hasDateError}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm' className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
> >
Apply Filter Apply Filter
+124 -687
View File
@@ -1,22 +1,25 @@
'use client'; 'use client';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import useSWR from 'swr';
import { import {
CellContext, ChangeEventHandler,
ColumnDef, useCallback,
SortingState, useEffect,
Updater, useMemo,
} from '@tanstack/react-table'; useState,
} from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import Link from 'next/link'; import Link from 'next/link';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Modal, { useModal } from '@/components/Modal'; import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal'; import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton'; import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent'; import PopoverContent from '@/components/popover/PopoverContent';
@@ -25,40 +28,17 @@ import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton'; import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal'; import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatDate } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase'; import { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase'; import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = {
search: string;
sort_by: string;
order_by: string;
start_date: string;
end_date: string;
filter_by: string;
po_date: string;
approval_status: string;
product_category_id: string;
product_category_name: string;
supplier_id: string;
supplier_name: string;
area_id: string;
area_name: string;
location_id: string;
location_name: string;
project_flock_id: string;
project_flock_name: string;
project_flock_kandang_id: string;
project_flock_kandang_name: string;
};
// ===== STATUS BADGE UTILITIES ===== // ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = { const statusTextMap: Record<string, string> = {
@@ -167,100 +147,42 @@ const RowOptionsMenu = ({
}; };
const PurchaseTable = () => { const PurchaseTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [sorting, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE ===== // ===== TABLE FILTER STATE =====
const { const {
state: tableFilterState, state: tableFilterState,
setFilters,
updateFilter, updateFilter,
setPage, setPage,
setPageSize, setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
} = useTableFilter<PurchaseTableFilters>({ } = useTableFilter({
initial: { initial: {
search: '', search: '',
sort_by: '',
order_by: '',
start_date: '',
end_date: '',
filter_by: '',
po_date: '', po_date: '',
approval_status: '', approval_status: '',
product_category_id: '', product_category_id: '',
product_category_name: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
pageSize: 'limit', pageSize: 'limit',
sort_by: 'sort_by',
order_by: 'sort_order',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
po_date: 'po_date', po_date: 'po_date',
approval_status: 'approval_status', approval_status: 'approval_status',
product_category_id: 'product_category_id', product_category_id: 'product_category_id',
supplier_id: 'supplier_id',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
}, },
excludeKeysFromUrl: [
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'purchase-table',
}); });
// ===== STATE MANAGEMENT =====
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [selectedPurchase, setSelectedPurchase] = useState<Purchase | null>(
null
);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== MODAL HOOKS ===== // ===== MODAL HOOKS =====
const filterModal = useModal(); const filterModal = useModal();
const deleteModal = useModal(); const deleteModal = useModal();
const exportProgressInputModal = useModal();
// ===== API DATA FETCHING ===== // ===== API DATA FETCHING =====
const { const {
@@ -272,10 +194,36 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher PurchaseApi.getAllFetcher
); );
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION ===== // ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [ const purchaseColumns: ColumnDef<Purchase>[] = [
{ {
accessorKey: 'po_number',
header: 'No. PR/PO', header: 'No. PR/PO',
cell: (props) => { cell: (props) => {
const { pr_number, po_number } = props.row.original; const { pr_number, po_number } = props.row.original;
@@ -291,91 +239,37 @@ const PurchaseTable = () => {
return ( return (
<ul className='list-disc pl-4'> <ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => { {poExpedition.map((exp, index) => {
return ( const expenseId = expenseMap.get(exp.refrence);
<li key={index}> if (expenseId) {
<Link return (
href={`/expense/detail/?expenseId=${exp.id}`} <li key={index}>
className='p-0 h-auto text-primary underline' <Link
> href={`/expense/detail/?expenseId=${expenseId}`}
{exp.refrence} className='p-0 h-auto text-primary underline'
</Link> >
</li> {exp.refrence}
); </Link>
</li>
);
}
return <li key={index}>{exp.refrence}</li>;
})} })}
</ul> </ul>
); );
}, },
}, },
{
accessorKey: 'requester_name',
header: 'Nama Pengaju',
cell: (props) => props.row.original.requester_name || '-',
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'received_date',
header: 'Tgl. Terima',
cell: (props) =>
props.row.original.received_date
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{
header: 'Aging',
enableSorting: false,
cell: (props) => {
const purchase = props.row.original;
if (!purchase.po_date) return '-';
const poDate = new Date(purchase.po_date);
const today = new Date();
const diffTime = Math.abs(today.getTime() - poDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `${diffDays} hari`;
},
},
{ {
accessorKey: 'supplier', accessorKey: 'supplier',
header: 'Vendor', header: 'Vendor',
cell: (props) => props.row.original.supplier.name, cell: (props) => props.row.original.supplier.name,
}, },
{ {
accessorKey: 'location', accessorKey: 'requester_name',
header: 'Lokasi', header: 'Nama Pengaju',
cell: (props) => props.row.original.location?.name || '-', cell: (props) => props.row.original.requester_name || '-',
}, },
{ {
accessorKey: 'warehouse', accessorKey: 'products.name',
header: 'Gudang',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.warehouse?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products',
header: 'Produk', header: 'Produk',
cell: (props) => { cell: (props) => {
const products = props.row.original.products; const products = props.row.original.products;
@@ -390,162 +284,39 @@ const PurchaseTable = () => {
}, },
}, },
{ {
accessorKey: 'total_qty', accessorKey: 'location.name',
header: 'Kuantitas', header: 'Lokasi',
enableSorting: false, cell: (props) => props.row.original.location?.name || '-',
},
{
accessorKey: 'po_date',
header: 'Tgl. PO',
cell: (props) =>
props.row.original.po_date
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
cell: (props) =>
props.row.original.due_date
? formatDate(props.row.original.due_date, 'DD MMM YYYY')
: '-',
},
{
header: 'Aging',
cell: (props) => { cell: (props) => {
const items = props.row.original.items; const purchase = props.row.original;
if (!items || items.length === 0) return '-'; if (!purchase.po_date) return '-';
return ( const poDate = new Date(purchase.po_date);
<ul className='list-disc pl-4'> const today = new Date();
{items.map((item, index) => ( const diffTime = Math.abs(today.getTime() - poDate.getTime());
<li key={index}>{formatNumber(item.total_qty ?? 0)}</li> const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
))} return `${diffDays} hari`;
</ul>
);
}, },
}, },
{ {
accessorKey: 'uom',
header: 'Satuan',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.product?.uom?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'price',
header: 'Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'total_price',
header: 'Total Harga',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{formatCurrency(item.total_price ?? 0)}</li>
))}
</ul>
);
},
},
{
accessorKey: 'products_total',
header: 'Total Harga Produk',
cell: (props) => formatCurrency(props.row.original.products_total ?? 0),
},
{
accessorKey: 'expedition_vendor',
header: 'Vendor Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>{item.expedition_vendor?.name ?? '-'}</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_qty',
header: 'Qty Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_qty != null
? formatNumber(item.expedition_qty)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'transport_per_item',
header: 'Harga Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.transport_per_item != null
? formatCurrency(item.transport_per_item)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'item_expedition_total',
header: 'Total Ekspedisi',
enableSorting: false,
cell: (props) => {
const items = props.row.original.items;
if (!items || items.length === 0) return '-';
return (
<ul className='list-disc pl-4'>
{items.map((item, index) => (
<li key={index}>
{item.expedition_total != null
? formatCurrency(item.expedition_total)
: '-'}
</li>
))}
</ul>
);
},
},
{
accessorKey: 'expedition_total',
header: 'Total Ekspedisi Semua Produk',
cell: (props) => formatCurrency(props.row.original.expedition_total ?? 0),
},
{
accessorKey: 'grand_total_all',
header: 'Grand Total All',
cell: (props) => formatCurrency(props.row.original.grand_total_all ?? 0),
},
{
accessorKey: 'status',
header: 'Status Approval', header: 'Status Approval',
cell: (props) => { cell: (props) => {
const approval = props.row.original.latest_approval; const approval = props.row.original.latest_approval;
@@ -590,19 +361,6 @@ const PurchaseTable = () => {
); );
}, },
}, },
{
accessorKey: 'notes',
header: 'Notes',
cell: (props) => props.row.original.notes || '-',
},
{
accessorKey: 'created_at',
header: 'Tanggal Dibuat',
cell: (props) =>
props.row.original.created_at
? formatDate(props.row.original.created_at, 'DD MMM YYYY')
: '-',
},
{ {
header: 'Aksi', header: 'Aksi',
cell: (props) => { cell: (props) => {
@@ -634,17 +392,10 @@ const PurchaseTable = () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
try { try {
const deleteResponse = await PurchaseApi.delete( await PurchaseApi.delete(selectedPurchase?.id as number);
selectedPurchase?.id as number refreshPurchaseRequests();
); deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
if (isResponseSuccess(deleteResponse)) {
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} else {
toast.error(deleteResponse?.message ?? 'Gagal menghapus data!');
}
} catch { } catch {
toast.error('Gagal menghapus data permintaan pembelian!'); toast.error('Gagal menghapus data permintaan pembelian!');
} }
@@ -652,214 +403,34 @@ const PurchaseTable = () => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]); }, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('purchase-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback( const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => { (e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value); updateFilter('search', e.target.value);
}, },
[updateFilter] [updateFilter, setSearchValue]
); );
const filterSubmitHandler = (values: PurchaseFilter) => { const filterSubmitHandler = (values: PurchaseFilter) => {
setFilters({ updateFilter('po_date', values.poDate);
start_date: values.start_date || '', updateFilter('product_category_id', values.category.join(','));
end_date: values.end_date || '', updateFilter('approval_status', values.status.join(','));
filter_by: values.filterBy?.value || '',
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
values.category_labels?.map((item) => item.label).join(',') || '',
approval_status: values.status.join(','),
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
supplier_name: values.supplier_label || '',
area_id: values.area_id ? String(values.area_id) : '',
area_name: values.area_label || '',
location_id: values.location_id ? String(values.location_id) : '',
location_name: values.location_label || '',
project_flock_id: values.project_flock_id
? String(values.project_flock_id)
: '',
project_flock_name: values.project_flock_label || '',
project_flock_kandang_id: values.project_flock_kandang_id
? String(values.project_flock_kandang_id)
: '',
project_flock_kandang_name: values.project_flock_kandang_label || '',
});
}; };
const filterResetHandler = () => { const filterResetHandler = () => {
setFilters({ updateFilter('po_date', '');
start_date: '', updateFilter('product_category_id', '');
end_date: '', updateFilter('approval_status', '');
filter_by: '',
po_date: '',
product_category_id: '',
product_category_name: '',
approval_status: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
});
}; };
const purchaseFilterInitialValues = useMemo(() => {
const filterByLabelMap: Record<string, string> = {
po_date: 'Tanggal PO',
received_date: 'Tanggal Terima',
due_date: 'Tanggal Jatuh Tempo',
created_at: 'Tanggal Dibuat',
};
const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const categoryLabels = tableFilterState.product_category_name
? tableFilterState.product_category_name
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const approvalStatuses = tableFilterState.approval_status
? tableFilterState.approval_status
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return {
poDate: tableFilterState.po_date,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
filterBy: tableFilterState.filter_by
? {
value: tableFilterState.filter_by,
label:
filterByLabelMap[tableFilterState.filter_by] ||
tableFilterState.filter_by,
}
: undefined,
category: categoryIds.map((value, index) => ({
value: Number(value),
label: categoryLabels[index] || value,
})),
status: approvalStatuses.map((value) => ({
value,
label:
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
?.step_name || value,
})),
supplier: tableFilterState.supplier_id
? ({
value: Number(tableFilterState.supplier_id),
label:
tableFilterState.supplier_name || tableFilterState.supplier_id,
} as OptionType<number>)
: null,
area: tableFilterState.area_id
? ({
value: Number(tableFilterState.area_id),
label: tableFilterState.area_name || tableFilterState.area_id,
} as OptionType<number>)
: null,
location: tableFilterState.location_id
? ({
value: Number(tableFilterState.location_id),
label:
tableFilterState.location_name || tableFilterState.location_id,
} as OptionType<number>)
: null,
project_flock: tableFilterState.project_flock_id
? ({
value: Number(tableFilterState.project_flock_id),
label:
tableFilterState.project_flock_name ||
tableFilterState.project_flock_id,
} as OptionType<number>)
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? ({
value: Number(tableFilterState.project_flock_kandang_id),
label:
tableFilterState.project_flock_kandang_name ||
tableFilterState.project_flock_kandang_id,
} as OptionType<number>)
: null,
};
}, [tableFilterState]);
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressStartDate(e.target.value);
}, []);
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressEndDate(e.target.value);
}, []);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await PurchaseApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
return ( return (
<> <>
<div className='w-full'> <div className='w-full'>
@@ -906,73 +477,11 @@ const PurchaseTable = () => {
'search', 'search',
'filter_by', 'filter_by',
'sort_by', 'sort_by',
'order_by',
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
]} ]}
fieldGroups={[['start_date', 'end_date']]} fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal} onClick={filterModal.openModal}
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={exportToExcel}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div> </div>
</div> </div>
@@ -1020,8 +529,7 @@ const PurchaseTable = () => {
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
isLoading={isLoading} isLoading={isLoading}
sorting={sorting} sorting={sorting}
setSorting={handleSortingChange} setSorting={setSorting}
manualSorting
className={{ className={{
containerClassName: cn('p-3 mb-0'), containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap', headerColumnClassName: 'text-nowrap',
@@ -1035,7 +543,6 @@ const PurchaseTable = () => {
<PurchaseFilterModal <PurchaseFilterModal
ref={filterModal.ref} ref={filterModal.ref}
initialValues={purchaseFilterInitialValues}
onSubmit={filterSubmitHandler} onSubmit={filterSubmitHandler}
onReset={filterResetHandler} onReset={filterResetHandler}
/> />
@@ -1055,76 +562,6 @@ const PurchaseTable = () => {
onClick: confirmationModalDeleteClickHandler, onClick: confirmationModalDeleteClickHandler,
}} }}
/> />
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
</> </>
); );
}; };
@@ -55,6 +55,7 @@ const PurchaseRequestForm = ({
const deleteModal = useModal(); const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>( const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[] []
); );
@@ -162,7 +163,6 @@ const PurchaseRequestForm = ({
options: locationOptions, options: locationOptions,
isLoadingOptions: isLoadingLocations, isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations, loadMore: loadMoreLocations,
setInputValue: setLocationSelectInputValue,
} = useSelect(LocationApi.basePath, 'id', 'name', '', { } = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id: area_id:
selectedArea != '' selectedArea != ''
@@ -26,8 +26,6 @@ import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/or
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice'; import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import Card from '@/components/Card'; import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import { import {
CreateAcceptApprovalRequestPayload, CreateAcceptApprovalRequestPayload,
CreateManagerApprovalRequestPayload, CreateManagerApprovalRequestPayload,
@@ -98,7 +96,6 @@ const PurchaseOrderDetail = ({
const acceptRejectionModal = useModal(); const acceptRejectionModal = useModal();
const managerRejectionModal = useModal(); const managerRejectionModal = useModal();
const editModal = useModal(); const editModal = useModal();
const editPoDateModal = useModal();
const penerimaanBarangModal = useModal(); const penerimaanBarangModal = useModal();
const deleteModal = useModal(); const deleteModal = useModal();
@@ -108,9 +105,6 @@ const PurchaseOrderDetail = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null); const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null);
const [, setApprovalNotes] = useState(''); const [, setApprovalNotes] = useState('');
const [managerApprovalNotes, setManagerApprovalNotes] = useState('');
const [managerApprovalPoDate, setManagerApprovalPoDate] = useState('');
const [editPoDate, setEditPoDate] = useState('');
const selectedRowIds = Object.keys(rowSelection).map((item) => const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item) parseInt(item)
@@ -218,8 +212,6 @@ const PurchaseOrderDetail = ({
break; break;
case 2: case 2:
setApprovalNotes(''); setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.openModal(); confirmationModalWithNotes.openModal();
break; break;
case 3: case 3:
@@ -422,50 +414,17 @@ const PurchaseOrderDetail = ({
deleteModal, deleteModal,
]); ]);
const updatePoDateHandler = useCallback(async () => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
toast.error('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.updatePoDate(purchaseRequestId, {
po_date: editPoDate,
});
if (isResponseError(res)) {
toast.error(res.message || 'Gagal mengubah tanggal PO');
return;
}
toast.success('Tanggal PO berhasil diubah');
setEditPoDate('');
editPoDateModal.closeModal();
refetchData?.();
}, [
initialValues?.id,
searchParams,
editPoDate,
editPoDateModal,
refetchData,
]);
// ===== APPROVAL/REJECTION HANDLERS ===== // ===== APPROVAL/REJECTION HANDLERS =====
const managerApprovalHandler = async () => { const managerApprovalHandler = async (notes: string) => {
const payload: CreateManagerApprovalRequestPayload = { const payload: CreateManagerApprovalRequestPayload = {
action: 'APPROVED', action: 'APPROVED',
notes: managerApprovalNotes || null, notes: notes || null,
po_date: managerApprovalPoDate || null,
}; };
await createManagerApprovalHandler(payload); await createManagerApprovalHandler(payload);
await refreshApprovals(); await refreshApprovals();
await refetchData?.(); await refetchData?.();
setManagerApprovalNotes(''); setApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal(); confirmationModalWithNotes.closeModal();
}; };
@@ -870,41 +829,6 @@ const PurchaseOrderDetail = ({
</div> </div>
</div> </div>
</div> </div>
{purchaseData.po_date &&
!purchaseData.po_date.startsWith('0001') && (
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Tanggal PO
</span>
<div className='ml-3 flex items-center gap-1'>
<span className='text-gray-900'>
: {formatDate(purchaseData.po_date, 'DD MMM YYYY')}
</span>
<RequirePermission permissions='lti.purchase.update'>
<Button
type='button'
variant='ghost'
color='warning'
className='p-1 min-h-0 h-auto'
onClick={() => {
setEditPoDate(
formatDate(purchaseData.po_date, 'YYYY-MM-DD')
);
editPoDateModal.openModal();
}}
>
<Icon
icon='material-symbols:edit-outline'
width={14}
height={14}
/>
</Button>
</RequirePermission>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1092,79 +1016,27 @@ const PurchaseOrderDetail = ({
</div> </div>
</Card> </Card>
{/* Manager Approval Modal */} {/* Confirmation Modal with Notes */}
<Modal <ConfirmationModalWithNotes
ref={confirmationModalWithNotes.ref} ref={confirmationModalWithNotes.ref}
type='success'
text='Apakah Anda yakin ingin melanjutkan approval ini?'
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
rows={4}
closeOnBackdrop closeOnBackdrop
className={{ primaryButton={{
modalBox: 'max-w-lg rounded-lg p-0', text: 'Ya, Lanjutkan',
color: 'success',
onClick: managerApprovalHandler,
}} }}
> secondaryButton={{
<div className='flex flex-col'> text: 'Batal',
<div className='flex items-center justify-between border-b border-base-content/10 p-4'> onClick: () => {
<h4 className='text-sm font-semibold text-base-content'> setApprovalNotes('');
Konfirmasi Approval Manager confirmationModalWithNotes.closeModal();
</h4> },
<Button }}
variant='ghost' />
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Apakah Anda yakin ingin melanjutkan approval ini?
</p>
<DateInput
name='manager_approval_po_date'
label='Tanggal PO'
value={managerApprovalPoDate}
onChange={(e) => setManagerApprovalPoDate(e.target.value)}
isNestedModal
/>
<TextArea
name='manager_approval_notes'
label='Catatan (Opsional)'
placeholder='Tambahkan catatan untuk approval ini...'
value={managerApprovalNotes}
onChange={(e) => setManagerApprovalNotes(e.target.value)}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={managerApprovalHandler}
className='px-3 py-2.5'
>
Ya, Lanjutkan
</Button>
</div>
</div>
</Modal>
{/* Staff Approval Modal */} {/* Staff Approval Modal */}
<Modal <Modal
@@ -1240,66 +1112,6 @@ const PurchaseOrderDetail = ({
/> />
</Modal> </Modal>
{/* Edit PO Date Modal */}
<Modal
ref={editPoDateModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-sm rounded-lg p-0',
}}
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Edit Tanggal PO
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='edit_po_date'
label='Tanggal PO'
value={editPoDate}
onChange={(e) => setEditPoDate(e.target.value)}
isNestedModal
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='primary'
onClick={updatePoDateHandler}
className='px-3 py-2.5'
disabled={!editPoDate}
>
Simpan
</Button>
</div>
</div>
</Modal>
{/* Staff Rejection Modal */} {/* Staff Rejection Modal */}
<ConfirmationModalWithNotes <ConfirmationModalWithNotes
ref={staffRejectionModal.ref} ref={staffRejectionModal.ref}
@@ -1,38 +1,20 @@
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab'; import ReportExpenseTab from './tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
const VALID_TAB_IDS = ['operational-expense', 'depreciation'];
const ReportExpenseTabs = () => { const ReportExpenseTabs = () => {
const router = useRouter(); const [activeTabId, setActiveTabId] = useState<string>('1');
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'operational-expense';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'operational-expense';
const tabActions = useTabActionsStore((state) => state.tabActions); const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [ const tabs = [
{ {
id: 'operational-expense', id: '1',
label: 'Laporan Biaya Operasional', label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'operational-expense'} />, content: <ReportExpenseTab tabId={'1'} />,
},
{
id: 'depreciation',
label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'depreciation'} />,
}, },
]; ];
@@ -42,7 +24,7 @@ const ReportExpenseTabs = () => {
tabs={tabs} tabs={tabs}
variant='boxed' variant='boxed'
activeTabId={activeTabId} activeTabId={activeTabId}
onTabChange={handleTabChange} onTabChange={setActiveTabId}
className={{ className={{
tabHeaderWrapper: tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10', 'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,26 +1,27 @@
import React from 'react'; import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
type ReportSkeletonColumn<TData extends object> = type ReportExpenseColumn =
| ColumnDef<TData> | ColumnDef<ReportExpense>
| { | {
header: string; header: string;
columns: Array<{ columns: Array<{
header: string; header: string;
accessorKey?: string; accessorKey?: string;
cell?: (props: { row: { original: TData } }) => React.ReactNode; cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode;
}>; }>;
}; };
const ReportExpenseSkeleton = <TData extends object>({ const ReportExpenseSkeleton = ({
columns, columns,
icon, icon,
title, title,
subtitle, subtitle,
}: { }: {
columns: ReportSkeletonColumn<TData>[]; columns: ReportExpenseColumn[];
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
subtitle: string; subtitle: string;
@@ -1,237 +0,0 @@
'use client';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import * as yup from 'yup';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import { AreaApi, LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
export type ReportDepreciationFilterValues = {
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string | null;
};
export const ReportDepreciationFilterSchema = yup.object({
area: yup.mixed<OptionType<string>>().optional(),
location: yup.mixed<OptionType<string>>().optional(),
projectFlock: yup.mixed<OptionType<string>>().optional(),
period: yup.string().nullable().required('Periode wajib dipilih'),
});
interface ReportDepreciationFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: Partial<ReportDepreciationFilterValues>;
onSubmit?: (values: Partial<ReportDepreciationFilterValues>) => void;
onReset?: () => void;
}
const defaultInitialValues: (
initialValues?: Partial<ReportDepreciationFilterValues>
) => ReportDepreciationFilterValues = (initialValues) => ({
area: undefined,
location: undefined,
projectFlock: undefined,
period: initialValues?.period ?? null,
});
const ReportDepreciationFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: ReportDepreciationFilterModalProps) => {
const closeModalHandler = () => {
ref.current?.close();
};
const formik = useFormik<ReportDepreciationFilterValues>({
initialValues: { ...defaultInitialValues(initialValues), ...initialValues },
validationSchema: ReportDepreciationFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
});
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: String(formik.values.area?.value ?? ''),
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: String(formik.values.location?.value ?? ''),
}
);
const formikResetHandler = () => {
onReset?.();
formik.resetForm({ values: defaultInitialValues(initialValues) });
closeModalHandler();
};
const areaChangeHandler = (val: OptionType | OptionType[] | null) => {
const area =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('area', area);
formik.setFieldValue('location', undefined);
formik.setFieldValue('projectFlock', undefined);
};
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const location =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('location', location);
formik.setFieldValue('projectFlock', undefined);
};
const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => {
const projectFlock =
val && !Array.isArray(val) ? (val as OptionType<string>) : undefined;
formik.setFieldValue('projectFlock', projectFlock);
};
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl xl:max-w-4/12 max-w-sm',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
<div className='p-4 flex items-center justify-between gap-2 border-b border-base-content/10'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={closeModalHandler}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={formik.values.area ?? null}
onChange={areaChangeHandler}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isLoading={isLoadingAreaOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.location ?? null}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isLoading={isLoadingLocationOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.projectFlock ?? null}
onChange={projectFlockChangeHandler}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isLoading={isLoadingProjectFlockOptions}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<DateInput
label='Periode'
name='period'
placeholder='Pilih Periode'
value={formik.values.period || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={formik.touched.period && !!formik.errors.period}
errorMessage={formik.errors.period}
required
isNestedModal
/>
</div>
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default ReportDepreciationFilterModal;
@@ -1,264 +0,0 @@
'use client';
import React, { useEffect, useMemo } from 'react';
import useSWR from 'swr';
import { ColumnDef } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Card from '@/components/Card';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
import { useModal } from '@/components/Modal';
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense';
import { DepreciationReportApi } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface ReportDepreciationTabProps {
tabId: string;
}
const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
area?: OptionType<string>;
location?: OptionType<string>;
projectFlock?: OptionType<string>;
period: string;
}>({
initial: {
area: undefined,
location: undefined,
projectFlock: undefined,
period: formatDate(Date.now(), 'YYYY-MM-DD'),
},
paramMap: {
pageSize: 'limit',
area: 'area_id',
location: 'location_id',
projectFlock: 'project_flock_id',
period: 'period',
},
persist: true,
storeName: 'report-depreciation-table',
});
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
useSWR(
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`,
DepreciationReportApi.getAllFetcher
);
const depreciations = isResponseSuccess(depreciationsResponse)
? depreciationsResponse.data
: [];
const filterModal = useModal();
const { ref: filterModalRef } = filterModal;
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const depreciationKandangColumns: ColumnDef<
ReportDepreciation['components']['kandang'][0]
>[] = [
{
accessorKey: 'kandang_name',
header: 'Kandang',
},
{
accessorKey: 'house_type',
header: 'Tipe Kandang',
cell: ({ row }) => row.original.house_type.toUpperCase(),
},
{
accessorKey: 'depreciation_percent',
header: 'Persentase Depresiasi',
cell: ({ row }) => row.original.depreciation_percent + '%',
},
{
accessorKey: 'depreciation_value',
header: 'Nilai Depresiasi',
cell: ({ row }) => formatCurrency(row.original.depreciation_value),
},
{
accessorKey: 'depreciation_source',
header: 'Asal Depresiasi',
cell: ({ row }) => row.original.depreciation_source.toUpperCase(),
},
{
accessorKey: 'cutover_date',
header: 'Tanggal Cutover',
cell: ({ row }) => formatDate(row.original.cutover_date, 'DD MMM YYYY'),
},
{
accessorKey: 'origin_date',
header: 'Tanggal Origin',
cell: ({ row }) => formatDate(row.original.origin_date, 'DD MMM YYYY'),
},
];
const tabActionsElement = useMemo(
() => (
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize']}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
),
[tableFilterState]
);
useEffect(() => {
setTabActions(tabId, tabActionsElement);
}, [setTabActions, tabActionsElement, tabId]);
useEffect(() => {
return () => {
clearTabActions(tabId);
};
}, [clearTabActions, tabId]);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoadingDepreciations && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoadingDepreciations && depreciations.length === 0 && (
<ReportExpenseSkeleton
columns={depreciationKandangColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoadingDepreciations && depreciations.length > 0 && (
<>
{depreciations.map((depreciationItem, idx) => (
<Card
key={idx}
title={depreciationItem.farm_name}
subtitle={`Period: ${formatDate(depreciationItem.period, 'DD MMM YYYY')} | Depresiasi Efektif: ${formatNumber(depreciationItem.depreciation_percent_effective, 'en-US', 0, 10)}% | Nilai Depresiasi: ${formatCurrency(depreciationItem.depreciation_value)} | Total Pullet Cost: ${formatCurrency(depreciationItem.pullet_cost_day_n_total, 'IDR', 'id-ID', 0, 10)}`}
className={{
wrapper: 'w-full rounded-lg border-none',
body: 'p-0',
title:
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
subtitle:
'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
collapsible: 'rounded-lg',
}}
variant='bordered'
collapsible={true}
>
<Table
data={depreciationItem.components.kandang}
columns={depreciationKandangColumns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.page
: 0
}
totalItems={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingDepreciations}
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName:
'overflow-x-auto rounded-tr-none rounded-tl-none',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
tableFooterClassName:
'bg-gray-100 font-semibold border border-gray-200',
footerRowClassName: 'border-t-2 border-gray-300',
footerColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
paginationClassName: 'hidden',
}}
/>
</Card>
))}
<Pagination
totalItems={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.total_results ?? 0)
: 0
}
itemsPerPage={tableFilterState.pageSize}
currentPage={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.page ?? 0)
: 0
}
onPrevPage={() => setPage(tableFilterState.page - 1)}
onNextPage={() => setPage(tableFilterState.page + 1)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</>
)}
</div>
<ReportDepreciationFilterModal
ref={filterModalRef}
initialValues={tableFilterState}
onReset={resetFilter}
onSubmit={(values) => {
updateFilter('area', values.area, true);
updateFilter('location', values.location, true);
updateFilter('projectFlock', values.projectFlock, true);
updateFilter(
'period',
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
true
);
}}
/>
</>
);
};
export default ReportDepreciationTab;
@@ -23,8 +23,8 @@ import RealizationStatusBadge from '@/components/pages/expense/RealizationStatus
import Table from '@/components/Table'; import Table from '@/components/Table';
import { formatCurrency, formatDate } from '@/lib/helper'; import { formatCurrency, formatDate } from '@/lib/helper';
import { ReportExpense } from '@/types/api/report/report-expense'; import { ReportExpense } from '@/types/api/report/report-expense';
import { ReportExpenseApi } from '@/services/api/report/expense-report'; import { ReportExpenseApi } from '@/services/api/report';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
@@ -39,7 +39,7 @@ import {
} from '@/services/api/master-data'; } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
import { Nonstock } from '@/types/api/master-data/nonstock'; import { Nonstock } from '@/types/api/master-data/nonstock';
import { ColumnDef, SortingState, Updater } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { httpClient } from '@/services/http/client'; import { httpClient } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
@@ -73,25 +73,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
// ===== SORTING STATE =====
const [sortBy, setSortBy] = useState('');
const [orderBy, setOrderBy] = useState('');
const sorting: SortingState = sortBy
? [{ id: sortBy, desc: orderBy === 'desc' }]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
setSortBy(next[0].id);
setOrderBy(next[0].desc ? 'desc' : 'asc');
} else {
setSortBy('');
setOrderBy('');
}
};
const handleFilterModalOpenRef = useRef(() => {}); const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
@@ -145,49 +126,8 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
}); });
handleFilterModalOpenRef.current = () => { handleFilterModalOpenRef.current = () => {
const restoredLocation = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredSupplier = filterParams.supplier_id
? supplierOptions.find(
(opt) => String(opt.value) === filterParams.supplier_id
) || {
value: filterParams.supplier_id,
label: filterParams.supplier_id,
}
: null;
const restoredKandang = filterParams.kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.kandang_id
) || { value: filterParams.kandang_id, label: filterParams.kandang_id }
: null;
const restoredNonstock = filterParams.nonstock_id
? nonstockOptions.find(
(opt) => String(opt.value) === filterParams.nonstock_id
) || {
value: filterParams.nonstock_id,
label: filterParams.nonstock_id,
}
: null;
const restoredCategory = filterParams.category
? categoryOptions.find((opt) => opt.value === filterParams.category) ||
null
: null;
formik.setValues({
location_id: restoredLocation,
supplier_id: restoredSupplier,
kandang_id: restoredKandang,
nonstock_id: restoredNonstock,
realization_date: filterParams.realization_date || null,
category: restoredCategory,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
// ===== OPTIONS ===== // ===== OPTIONS =====
@@ -249,49 +189,26 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
[formik.values.category] [formik.values.category]
); );
const buildReportExpenseQueryString = useCallback(
(extraParams?: Record<string, string>) => {
const params = new URLSearchParams();
if (filterParams.location_id) {
params.append('location_id', filterParams.location_id);
}
if (filterParams.supplier_id) {
params.append('supplier_id', filterParams.supplier_id);
}
if (filterParams.kandang_id) {
params.append('project_flock_kandang_id', filterParams.kandang_id);
}
if (filterParams.nonstock_id) {
params.append('nonstock_id', filterParams.nonstock_id);
}
if (filterParams.realization_date) {
params.append('realization_date', filterParams.realization_date);
}
if (filterParams.category) {
params.append('category', filterParams.category);
}
if (sortBy) params.append('sort_by', sortBy);
if (orderBy) params.append('sort_order', orderBy);
Object.entries(extraParams ?? {}).forEach(([key, value]) => {
params.set(key, value);
});
return params.toString();
},
[filterParams, sortBy, orderBy]
);
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
const { data: reportExpenseResponse, isLoading } = useSWR( const { data: reportExpenseResponse, isLoading } = useSWR(
() => { () => {
const queryString = buildReportExpenseQueryString({ const params = new URLSearchParams();
page: String(page), if (filterParams.location_id)
limit: String(pageSize), params.append('location_id', filterParams.location_id);
}); if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('project_flock_kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category)
params.append('category', filterParams.category);
params.append('page', String(page));
params.append('limit', String(pageSize));
return [`${ReportExpenseApi.basePath}?${queryString}`]; return [`${ReportExpenseApi.basePath}?${params.toString()}`];
}, },
([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url) ([url]: string[]) => httpClient<BaseApiResponse<ReportExpense[]>>(url)
); );
@@ -316,31 +233,47 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
const reportExpenseExport = useCallback(async (): Promise< const reportExpenseExport = useCallback(async (): Promise<
ReportExpense[] | null ReportExpense[] | null
> => { > => {
const queryString = buildReportExpenseQueryString({ const params = new URLSearchParams();
page: '1', if (filterParams.location_id)
limit: '100', params.append('location_id', filterParams.location_id);
}); if (filterParams.supplier_id)
params.append('supplier_id', filterParams.supplier_id);
if (filterParams.kandang_id)
params.append('kandang_id', filterParams.kandang_id);
if (filterParams.nonstock_id)
params.append('nonstock_id', filterParams.nonstock_id);
if (filterParams.realization_date)
params.append('realization_date', filterParams.realization_date);
if (filterParams.category) params.append('category', filterParams.category);
params.append('limit', '100');
params.append('page', '1');
const response = await httpClient<BaseApiResponse<ReportExpense[]>>( const response = await httpClient<BaseApiResponse<ReportExpense[]>>(
`${ReportExpenseApi.basePath}?${queryString}` `${ReportExpenseApi.basePath}?${params.toString()}`
); );
return isResponseSuccess(response) ? response.data : null; return isResponseSuccess(response) ? response.data : null;
}, [buildReportExpenseQueryString]); }, [filterParams]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
await ReportExpenseApi.exportToExcel(buildReportExpenseQueryString()); const allDataForExport = await reportExpenseExport();
} catch (error) {
toast.error( if (!allDataForExport || allDataForExport.length === 0) {
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran') toast.error('Tidak ada data untuk diekspor.');
); return;
}
await generateReportExpenseExcel(allDataForExport);
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [buildReportExpenseQueryString]); }, [reportExpenseExport]);
const handleExportPDF = useCallback(async () => { const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -464,23 +397,19 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
return [ return [
{ {
header: 'No', header: 'No',
enableSorting: false,
cell: (props) => (page - 1) * pageSize + props.row.index + 1, cell: (props) => (page - 1) * pageSize + props.row.index + 1,
}, },
{ {
header: 'No. PO', header: 'No. PO',
accessorKey: 'po_number', accessorKey: 'po_number',
enableSorting: true,
}, },
{ {
header: 'No. Referensi', header: 'No. Referensi',
accessorKey: 'reference_number', accessorKey: 'reference_number',
enableSorting: true,
}, },
{ {
header: 'Tanggal Realisasi', header: 'Tanggal Realisasi',
accessorKey: 'realization_date', accessorKey: 'realization_date',
enableSorting: true,
cell: ({ row }) => { cell: ({ row }) => {
return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); return formatDate(row.original?.realization_date, 'DD MMM, YYYY');
}, },
@@ -488,7 +417,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{ {
header: 'Tanggal Transaksi', header: 'Tanggal Transaksi',
accessorKey: 'transaction_date', accessorKey: 'transaction_date',
enableSorting: true,
cell: ({ row }) => { cell: ({ row }) => {
return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); return formatDate(row.original?.transaction_date, 'DD MMM, YYYY');
}, },
@@ -496,30 +424,21 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
{ {
header: 'Kategori', header: 'Kategori',
accessorKey: 'category', accessorKey: 'category',
enableSorting: true,
}, },
{ {
header: 'Produk', header: 'Produk',
accessorKey: 'product',
enableSorting: true,
accessorFn: (row) => row.pengajuan?.nonstock?.name, accessorFn: (row) => row.pengajuan?.nonstock?.name,
}, },
{ {
header: 'Supplier', header: 'Supplier',
accessorKey: 'supplier',
enableSorting: true,
accessorFn: (row) => row.supplier?.name, accessorFn: (row) => row.supplier?.name,
}, },
{ {
header: 'Lokasi', header: 'Lokasi',
accessorKey: 'location',
enableSorting: true,
accessorFn: (row) => row.kandang?.location?.name, accessorFn: (row) => row.kandang?.location?.name,
}, },
{ {
header: 'Kandang', header: 'Kandang',
accessorKey: 'kandang',
enableSorting: true,
accessorFn: (row) => row.kandang?.name, accessorFn: (row) => row.kandang?.name,
}, },
{ {
@@ -527,19 +446,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [ columns: [
{ {
header: 'Qty', header: 'Qty',
accessorKey: 'qty_pengajuan', id: 'qty_pengajuan',
accessorFn: (row) => row.pengajuan?.qty,
cell: ({ row }) => cell: ({ row }) =>
row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0',
}, },
{ {
header: 'Harga', header: 'Harga',
accessorKey: 'price_pengajuan', id: 'harga_pengajuan',
accessorFn: (row) => row.pengajuan?.price,
cell: ({ row }) => cell: ({ row }) =>
formatCurrency(row.original.pengajuan?.price || 0), formatCurrency(row.original.pengajuan?.price || 0),
}, },
{ {
header: 'Total', header: 'Total',
accessorKey: 'total_pengajuan', id: 'total_pengajuan',
accessorFn: (row) =>
(row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0),
cell: ({ row }) => { cell: ({ row }) => {
const total = const total =
(row.original.pengajuan?.qty || 0) * (row.original.pengajuan?.qty || 0) *
@@ -554,19 +477,23 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
columns: [ columns: [
{ {
header: 'Qty', header: 'Qty',
accessorKey: 'qty_realisasi', id: 'qty_realisasi',
accessorFn: (row) => row.realisasi?.qty,
cell: ({ row }) => cell: ({ row }) =>
row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', row.original.realisasi?.qty?.toLocaleString('id-ID') || '0',
}, },
{ {
header: 'Harga', header: 'Harga',
accessorKey: 'price_realisasi', id: 'harga_realisasi',
accessorFn: (row) => row.realisasi?.price,
cell: ({ row }) => cell: ({ row }) =>
formatCurrency(row.original.realisasi?.price || 0), formatCurrency(row.original.realisasi?.price || 0),
}, },
{ {
header: 'Total', header: 'Total',
accessorKey: 'total_realisasi', id: 'total_realisasi',
accessorFn: (row) =>
(row.realisasi?.qty || 0) * (row.realisasi?.price || 0),
cell: ({ row }) => { cell: ({ row }) => {
const total = const total =
(row.original.realisasi?.qty || 0) * (row.original.realisasi?.qty || 0) *
@@ -577,7 +504,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
], ],
}, },
{ {
id: 'realization_status',
header: 'Status Pencairan', header: 'Status Pencairan',
cell: (props) => ( cell: (props) => (
<RealizationStatusBadge <RealizationStatusBadge
@@ -586,7 +512,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
), ),
}, },
{ {
id: 'bop_status',
header: 'Status BOP', header: 'Status BOP',
cell: (props) => ( cell: (props) => (
<ExpenseStatusBadge approval={props.row.original?.latest_approval} /> <ExpenseStatusBadge approval={props.row.original?.latest_approval} />
@@ -631,9 +556,6 @@ const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => {
totalItems={meta?.total_results || 0} totalItems={meta?.total_results || 0}
onPageChange={setPage} onPageChange={setPage}
onPageSizeChange={setPageSize} onPageSizeChange={setPageSize}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
className={{ className={{
containerClassName: 'w-full mb-0!', containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto', tableWrapperClassName: 'overflow-x-auto',
@@ -1,47 +1,25 @@
'use client'; 'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab';
import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab';
import BalanceMonitoringTab from '@/components/pages/report/finance/tab/BalanceMonitoringTab';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
const VALID_TAB_IDS = [
'debt-supplier',
'customer-payment',
'balance-monitoring',
];
const FinanceTabs = () => { const FinanceTabs = () => {
const router = useRouter(); const [activeTabId, setActiveTabId] = useState<string>('1');
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') ?? 'debt-supplier';
const activeTabId = VALID_TAB_IDS.includes(tabParam)
? tabParam
: 'debt-supplier';
const tabActions = useTabActionsStore((state) => state.tabActions); const tabActions = useTabActionsStore((state) => state.tabActions);
const handleTabChange = (tabId: string) => {
router.push(`${pathname}?tab=${tabId}`);
};
const tabs = [ const tabs = [
{ {
id: 'debt-supplier', id: '1',
label: 'Rekapitulasi Hutang Ke Supplier', label: 'Rekapitulasi Hutang Ke Supplier',
content: <DebtSupplierTab tabId={'debt-supplier'} />, content: <DebtSupplierTab tabId={'1'} />,
}, },
{ {
id: 'customer-payment', id: '2',
label: 'Kontrol Pembayaran Customer', label: 'Kontrol Pembayaran Customer',
content: <CustomerPaymentTab tabId={'customer-payment'} />, content: <CustomerPaymentTab tabId={'2'} />,
},
{
id: 'balance-monitoring',
label: 'Monitoring Saldo',
content: <BalanceMonitoringTab tabId={'balance-monitoring'} />,
}, },
]; ];
@@ -51,7 +29,7 @@ const FinanceTabs = () => {
tabs={tabs} tabs={tabs}
variant='boxed' variant='boxed'
activeTabId={activeTabId} activeTabId={activeTabId}
onTabChange={handleTabChange} onTabChange={setActiveTabId}
className={{ className={{
tabHeaderWrapper: tabHeaderWrapper:
'justify-between items-center p-3 border-b border-base-content/10', 'justify-between items-center p-3 border-b border-base-content/10',
@@ -1,602 +0,0 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { ColumnDef } from '@tanstack/react-table';
import { AxiosError } from 'axios';
import { FinanceApi } from '@/services/api/report/finance-report';
import { CustomerApi } from '@/services/api/master-data';
import { UserApi } from '@/services/api/user';
import { useSelect, OptionType } from '@/components/input/SelectInput';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { BalanceMonitoringRow } from '@/types/api/report/balance-monitoring';
import { CustomerPaymentRow } from '@/types/api/report/customer-payment';
import Modal, { useModal } from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import Table from '@/components/Table';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
interface BalanceMonitoringTabProps {
tabId: string;
}
const filterByOptions: OptionType<string>[] = [
{ label: 'Tanggal Penjualan (SO Date)', value: 'sold_at' },
{ label: 'Tanggal Realisasi (Delivery Date)', value: 'realized_at' },
];
const BalanceMonitoringTab = ({ tabId }: BalanceMonitoringTabProps) => {
const [hasDateError, setHasDateError] = useState(false);
const [dateErrorShown, setDateErrorShown] = useState(false);
const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
salesPersons: OptionType<number>[];
filterBy?: OptionType<string>;
sort_by: string;
order_by: string;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
salesPersons: 'sales_ids',
filterBy: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
persist: true,
storeName: 'balance-monitoring-table',
});
// const sorting: SortingState = tableFilterState.sort_by
// ? [
// {
// id: tableFilterState.sort_by,
// desc: tableFilterState.order_by === 'desc',
// },
// ]
// : [];
// const handleSortingChange = (updater: Updater<SortingState>) => {
// const next = typeof updater === 'function' ? updater(sorting) : updater;
// if (next.length > 0) {
// updateFilter('sort_by', next[0].id, true);
// updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
// } else {
// updateFilter('sort_by', '', true);
// updateFilter('order_by', '', true);
// }
// };
const {
options: customerOptions,
setInputValue: setCustomerInput,
isLoadingOptions: isLoadingCustomers,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
const {
options: salesOptions,
setInputValue: setSalesInput,
isLoadingOptions: isLoadingSales,
loadMore: loadMoreSales,
} = useSelect(UserApi.basePath, 'id', 'name', 'search');
const formik = useFormik({
initialValues: {
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
},
onSubmit: (values) => {
updateFilter('start_date', values.start_date, true);
updateFilter('end_date', values.end_date, true);
updateFilter('customers', values.customers, true);
updateFilter('salesPersons', values.salesPersons, true);
updateFilter('filterBy', values.filterBy, true);
filterModal.closeModal();
},
});
const formikResetHandler = () => {
resetFilter();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
salesPersons: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
const { data: balanceMonitoringsResponse, isLoading } = useSWR<
BaseApiResponse<BalanceMonitoringRow[]>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
httpClientFetcher
);
const balanceMonitorings: BalanceMonitoringRow[] = isResponseSuccess(
balanceMonitoringsResponse
)
? ((balanceMonitoringsResponse.data as BalanceMonitoringRow[]) ?? [])
: [];
const meta =
isResponseSuccess(balanceMonitoringsResponse) &&
balanceMonitoringsResponse.meta
? balanceMonitoringsResponse.meta
: null;
// Inject tab actions directly — no nested component, no remount cycle
useEffect(() => {
setTabActions(
tabId,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
salesPersons: tableFilterState.salesPersons,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
);
}, [tabId, setTabActions, tableFilterState, filterModal.openModal]);
useEffect(() => {
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const columns = useMemo(
(): ColumnDef<BalanceMonitoringRow>[] => [
{
header: 'No',
enableSorting: false,
cell: (props) =>
(tableFilterState.page - 1) * tableFilterState.pageSize +
props.row.index +
1,
},
{
header: 'Customer',
accessorKey: 'customer.name',
enableSorting: true,
id: 'customer_name',
cell: ({ row }) => row.original.customer.name,
},
{
header: 'Saldo Awal',
accessorKey: 'saldo_awal',
id: 'saldo_awal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.saldo_awal)}
</div>
),
},
{
header: 'Penjualan Ayam',
columns: [
{
header: 'Ekor',
accessorKey: 'penjualan_ayam.ekor',
id: 'penjualan_ayam_ekor',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.ekor)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_ayam.kg',
id: 'penjualan_ayam_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_ayam.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_ayam.nominal',
id: 'penjualan_ayam_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_ayam.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Telur',
columns: [
{
header: 'Butir',
accessorKey: 'penjualan_telur.butir',
id: 'penjualan_telur_butir',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.butir)}
</div>
),
},
{
header: 'Kg',
accessorKey: 'penjualan_telur.kg',
id: 'penjualan_telur_kg',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.penjualan_telur.kg)}
</div>
),
},
{
header: 'Nominal',
accessorKey: 'penjualan_telur.nominal',
id: 'penjualan_telur_nominal',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_telur.nominal)}
</div>
),
},
],
},
{
header: 'Penjualan Trading',
accessorKey: 'penjualan_trading.nominal',
id: 'penjualan_trading',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.penjualan_trading.nominal)}
</div>
),
},
{
header: 'Pembayaran',
accessorKey: 'pembayaran',
id: 'pembayaran',
enableSorting: true,
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.pembayaran)}
</div>
),
},
{
header: 'Aging',
accessorKey: 'aging',
id: 'aging',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging)} hari
</div>
),
},
{
header: 'Aging Rata-Rata',
accessorKey: 'aging_rata_rata',
id: 'aging_rata_rata',
enableSorting: true,
cell: ({ row }) => (
<div className='text-center'>
{formatNumber(row.original.aging_rata_rata)} hari
</div>
),
},
{
header: 'Saldo Akhir',
accessorKey: 'saldo_akhir',
id: 'saldo_akhir',
enableSorting: true,
cell: ({ row }) => (
<div
className={`text-right font-semibold ${row.original.saldo_akhir < 0 ? 'text-error' : ''}`}
>
{formatCurrency(row.original.saldo_akhir)}
</div>
),
},
],
[tableFilterState.page, tableFilterState.pageSize]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
)}
{!isLoading && balanceMonitorings.length === 0 && (
<CustomerSupplierSkeleton
columns={columns as unknown as ColumnDef<CustomerPaymentRow>[]}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
)}
{!isLoading && balanceMonitorings.length > 0 && (
<>
<div className='w-full overflow-x-auto'>
<Table
data={balanceMonitorings}
columns={columns}
pageSize={tableFilterState.pageSize || 10}
page={tableFilterState.page || 1}
totalItems={meta?.total_results || 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
// sorting={sorting}
// setSorting={handleSortingChange}
// manualSorting
className={{
containerClassName: 'w-full mb-0!',
tableWrapperClassName: 'overflow-x-auto',
tableClassName: 'w-full table-auto text-sm',
headerRowClassName: 'border-b border-b-gray-200 bg-gray-50',
headerColumnClassName:
'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap',
bodyRowClassName:
'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200',
bodyColumnClassName:
'px-4 py-3 text-xs text-gray-900 whitespace-nowrap',
}}
/>
</div>
</>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={hasDateError}
/>
</div>
</div>
<SelectInputCheckbox
label='Customer'
placeholder='Pilih Customer'
options={customerOptions}
value={formik.values.customers}
onChange={(val) =>
formik.setFieldValue('customers', Array.isArray(val) ? val : [])
}
onInputChange={setCustomerInput}
isLoading={isLoadingCustomers}
isClearable
onMenuScrollToBottom={loadMoreCustomers}
className={{ wrapper: 'w-full' }}
/>
<SelectInputCheckbox
label='Sales'
placeholder='Pilih Sales'
options={salesOptions}
value={formik.values.salesPersons}
onChange={(val) =>
formik.setFieldValue(
'salesPersons',
Array.isArray(val) ? val : []
)
}
onInputChange={setSalesInput}
isLoading={isLoadingSales}
isClearable
onMenuScrollToBottom={loadMoreSales}
className={{ wrapper: 'w-full' }}
/>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filterBy ?? null}
onChange={(val) =>
formik.setFieldValue(
'filterBy',
!Array.isArray(val) ? (val ?? undefined) : undefined
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default BalanceMonitoringTab;
@@ -1,17 +1,14 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { AxiosError } from 'axios';
import Card from '@/components/Card'; import Card from '@/components/Card';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import { useSelect, OptionType } from '@/components/input/SelectInput'; import { useSelect } from '@/components/input/SelectInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInputRadio from '@/components/input/SelectInputRadio'; import SelectInputRadio from '@/components/input/SelectInputRadio';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { CustomerApi } from '@/services/api/master-data'; import { CustomerApi } from '@/services/api/master-data';
import { FinanceApi } from '@/services/api/report/finance-report'; import { FinanceApi } from '@/services/api/report/finance-report';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { import {
@@ -30,70 +27,55 @@ import Dropdown from '@/components/Dropdown';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
CustomerPaymentFilterSchema,
CustomerPaymentFilterType,
} from '@/components/pages/report/finance/filter/CustomerPaymentFilter';
import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX';
import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton';
import { OptionType } from '@/components/table/TableRowSizeSelector';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import Pagination from '@/components/Pagination';
import { useTableFilter } from '@/services/hooks/useTableFilter';
interface CustomerPaymentTabProps { interface CustomerPaymentTabProps {
tabId: string; tabId: string;
} }
const dataTypeOptions: OptionType<string>[] = [ interface FilterParams {
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' }, customer_ids?: string;
{ value: 'realization_date', label: 'Tanggal Realisasi' }, start_date?: string;
]; end_date?: string;
filter_by?: string;
}
const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] = const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== PAGINATION STATE =====
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<FilterParams>({});
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions); const dataTypeOptions = useMemo(
const clearTabActions = useTabActionsStore((state) => state.clearTabActions); () => [
{ value: 'trans_date', label: 'Tanggal Jual/Bayar' },
const { { value: 'realization_date', label: 'Tanggal Realisasi' },
state: tableFilterState, ],
updateFilter, []
setPage, );
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
customers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
customers: 'customer_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'customer-payment-report-table',
});
const { const {
options: customerOptions, options: customerOptions,
@@ -103,188 +85,223 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); } = useSelect(CustomerApi.basePath, 'id', 'name', 'search');
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik({ const formik = useFormik<CustomerPaymentFilterType>({
initialValues: { initialValues: {
start_date: tableFilterState.start_date, start_date: null,
end_date: tableFilterState.end_date, end_date: null,
customers: tableFilterState.customers, customer_ids: null,
filterBy: tableFilterState.filterBy, filter_by: null,
}, },
onSubmit: (values) => { validationSchema: CustomerPaymentFilterSchema,
updateFilter('start_date', values.start_date, true); onSubmit: (values, { setSubmitting }) => {
updateFilter('end_date', values.end_date, true); setFilterParams({
updateFilter('customers', values.customers, true); start_date: values.start_date || undefined,
updateFilter('filterBy', values.filterBy, true); end_date: values.end_date || undefined,
customer_ids: values.customer_ids || undefined,
filter_by: values.filter_by || undefined,
});
filterModal.closeModal();
setCurrentPage(1);
setSubmitting(false);
},
onReset: () => {
setFilterParams({});
setCurrentPage(1);
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
filterModal.closeModal(); filterModal.closeModal();
}, },
}); });
const formikResetHandler = () => { handleFilterModalOpenRef.current = () => {
resetFilter(); filterModal.openModal();
formik.validateForm();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
customers: [],
filterBy: undefined,
},
});
filterModal.closeModal();
}; };
const getPaymentStatusBadgeColor = (notes: string): Color => { const getPaymentStatusBadgeColor = (notes: string): Color => {
const normalizedValue = notes.toLowerCase(); const normalizedValue = notes.toLowerCase();
if (normalizedValue === 'lunas') return 'primary';
if (normalizedValue.includes('belum')) return 'warning'; if (normalizedValue === 'lunas') {
return 'primary';
}
if (normalizedValue.includes('belum')) {
return 'warning';
}
return 'neutral'; return 'neutral';
}; };
// ===== DATE CHANGE HANDLERS ===== // ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleStartDateChange = useCallback(
const value = e.target.value; (e: React.ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('start_date', value); const value = e.target.value;
formik.setFieldValue('start_date', value || null);
if (value && formik.values.end_date) { if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) { const startDate = new Date(value);
setHasDateError(true); const endDateObj = new Date(formik.values.end_date);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', { if (endDateObj < startDate) {
duration: Infinity, setHasDateError(true);
}); if (!dateErrorShown) {
setDateErrorShown(true); toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
} }
} else { } else {
setHasDateError(false); setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
} }
} else { },
setHasDateError(false); [formik, dateErrorShown]
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR<
BaseApiResponse<CustomerPaymentReport>,
AxiosError<BaseApiResponse>,
SWRHttpKey
>(
`${FinanceApi.basePath}/customer-payment${getTableFilterQueryString()}`,
httpClientFetcher
); );
const data: CustomerPaymentReport[] = isResponseSuccess(customerPayment) const handleEndDateChange = useCallback(
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || [] (e: React.ChangeEvent<HTMLInputElement>) => {
: []; const value = e.target.value;
formik.setFieldValue('end_date', value || null);
const meta = if (value && formik.values.start_date) {
isResponseSuccess(customerPayment) && customerPayment.meta const startDateObj = new Date(formik.values.start_date);
? customerPayment.meta const endDate = new Date(value);
: null;
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
},
[formik, dateErrorShown]
);
// ===== FILTER HELPERS =====
const customerIdsValue = useMemo(() => {
if (!formik.values.customer_ids) return [];
return customerOptions.filter((opt) =>
formik.values.customer_ids?.split(',').includes(String(opt.value))
);
}, [formik.values.customer_ids, customerOptions]);
const filterByValue = useMemo(() => {
if (!formik.values.filter_by) return null;
return (
dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) ||
null
);
}, [formik.values.filter_by, dataTypeOptions]);
// ===== DATA FETCHING =====
const { data: customerPayment, isLoading } = useSWR(
() => {
const params = {
customer_ids: filterParams.customer_ids,
filter_by: filterParams.filter_by as
| 'trans_date'
| 'realization_date'
| undefined,
start_date: filterParams.start_date,
end_date: filterParams.end_date,
page: currentPage,
limit: pageSize,
};
return ['customer-payment-report', params];
},
([, params]) =>
FinanceApi.getCustomerPaymentReport(
params.customer_ids,
params.filter_by,
params.start_date,
params.end_date,
params.page,
params.limit
)
);
const data: CustomerPaymentReport[] = useMemo(
() =>
isResponseSuccess(customerPayment)
? (customerPayment?.data as unknown as CustomerPaymentReport[]) || []
: [],
[customerPayment]
);
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const customerPaymentExport = useCallback(async (): Promise< const customerPaymentExport = useCallback(async (): Promise<
CustomerPaymentReport[] | null CustomerPaymentReport[] | null
> => { > => {
const customer_ids = const params = {
tableFilterState.customers.length > 0 customer_ids: filterParams.customer_ids,
? tableFilterState.customers.map((o) => String(o.value)).join(',') filter_by: filterParams.filter_by as
: undefined; | 'trans_date'
const filter_by = tableFilterState.filterBy?.value as | 'realization_date'
| 'trans_date' | undefined,
| 'realization_date' start_date: filterParams.start_date,
| undefined; end_date: filterParams.end_date,
limit: 100,
page: 1,
};
const response = await FinanceApi.getCustomerPaymentReport( const response = await FinanceApi.getCustomerPaymentReport(
customer_ids, params.customer_ids,
filter_by, params.filter_by,
tableFilterState.start_date || undefined, params.start_date,
tableFilterState.end_date || undefined, params.end_date,
1, params.page,
100 params.limit
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as CustomerPaymentReport[]) ? (response.data as unknown as CustomerPaymentReport[])
: null; : null;
}, [tableFilterState]); }, [filterParams]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const customer_ids =
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',')
: undefined;
await FinanceApi.exportCustomerPaymentToExcelGeneral(
customer_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const customer_ids = const allDataForExport = await customerPaymentExport();
tableFilterState.customers.length > 0
? tableFilterState.customers.map((o) => String(o.value)).join(',') if (
: undefined; !allDataForExport ||
await FinanceApi.exportCustomerPaymentToExcelCustomerPerSheet( !Array.isArray(allDataForExport) ||
customer_ids, allDataForExport.length === 0
tableFilterState.filterBy?.value, ) {
tableFilterState.start_date || undefined, toast.error('Tidak ada data untuk diekspor.');
tableFilterState.end_date || undefined return;
); }
await generateCustomerPaymentExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [tableFilterState]); }, [customerPaymentExport]);
const handleExportPdf = useCallback(async () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -300,18 +317,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return; return;
} }
const customerName = const customerName = filterParams.customer_ids
tableFilterState.customers.length > 0 ? customerOptions
? tableFilterState.customers.map((o) => o.label).join(', ') .filter((opt) =>
: 'Semua Customer'; filterParams.customer_ids?.split(',').includes(String(opt.value))
)
.map((opt) => opt.label)
.join(', ') || 'Semua Customer'
: 'Semua Customer';
await generateCustomerPaymentPDF({ await generateCustomerPaymentPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
customer_name: customerName, customer_name: customerName,
start_date: tableFilterState.start_date || undefined, start_date: filterParams.start_date,
end_date: tableFilterState.end_date || undefined, end_date: filterParams.end_date,
filter_by: tableFilterState.filterBy?.value as filter_by: filterParams.filter_by as
| 'trans_date' | 'trans_date'
| 'realization_date' | 'realization_date'
| undefined, | undefined,
@@ -323,103 +344,106 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [customerPaymentExport, tableFilterState]); }, [customerPaymentExport, filterParams, customerOptions]);
// ===== TAB ACTIONS ===== // ===== TAB ACTIONS COMPONENT =====
useEffect(() => { const TabActions = useMemo(() => {
setTabActions( return function TabActionsComponent() {
tabId, const setTabActions = useTabActionsStore((state) => state.setTabActions);
<div className='flex flex-row gap-3'> const clearTabActions = useTabActionsStore(
<ButtonFilter (state) => state.clearTabActions
values={{ );
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
customers: tableFilterState.customers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown useEffect(() => {
align='end' setTabActions(
direction='bottom' tabId,
className={{ <div className='flex flex-row gap-3'>
content: <ButtonFilter
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden', values={filterParams}
}} fieldGroups={[['start_date', 'end_date']]}
trigger={ onClick={() => handleFilterModalOpenRef.current()}
<Button
variant='outline' variant='outline'
color='none' className='px-3 py-2.5'
isLoading={isAnyExportLoading} />
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
> >
<div className='flex flex-row items-center gap-1.5'> <Button
<Icon variant='ghost'
icon='heroicons:cloud-arrow-down' color='none'
width={20} onClick={handleExportExcel}
height={20} isLoading={isExcelExportLoading}
/> className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
<span>Export</span> >
<div className='w-px self-stretch bg-base-content/10' /> <Icon icon='heroicons:table-cells' width={20} height={20} />
<Icon icon='heroicons:chevron-down' width={14} height={14} /> Export to Excel
</div> </Button>
</Button> <Button
} variant='ghost'
> color='none'
<Button onClick={handleExportPdf}
variant='ghost' isLoading={isPdfExportLoading}
color='none' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
onClick={handleExportExcel} >
isLoading={isExcelExportLoading} <Icon icon='heroicons:document' width={20} height={20} />
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' Export to PDF
> </Button>
<Icon icon='heroicons:table-cells' width={20} height={20} /> </Dropdown>
Export to Excel - Customer Per Sheet </div>
</Button> );
<Button }, [setTabActions]);
variant='ghost'
color='none' useEffect(() => {
onClick={handleExportExcelGeneral} return () => {
isLoading={isExcelGeneralExportLoading} clearTabActions(tabId);
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' };
> }, [clearTabActions]);
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - General return null;
</Button> };
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [ }, [
tabId, tabId,
setTabActions,
tableFilterState,
filterModal.openModal,
isAnyExportLoading, isAnyExportLoading,
handleExportExcel, handleExportExcel,
handleExportExcelGeneral,
handleExportPdf, handleExportPdf,
isExcelExportLoading, isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading, isPdfExportLoading,
filterParams,
]); ]);
useEffect(() => { const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
return () => clearTabActions(tabId);
}, [tabId, clearTabActions]);
const getTableColumns = ( const getTableColumns = (
summary: CustomerPaymentSummary summary: CustomerPaymentSummary
@@ -626,7 +650,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
enableSorting: false, enableSorting: false,
cell: (props) => { cell: (props) => {
const value = props.row.original.status; const value = props.row.original.status;
if (!value) return '-';
if (!value) {
return '-';
}
return ( return (
<StatusBadge <StatusBadge
color={getPaymentStatusBadgeColor(value)} color={getPaymentStatusBadgeColor(value)}
@@ -665,6 +693,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -688,27 +717,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
/> />
)} )}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading && {!isLoading &&
data.length > 0 && data.length > 0 &&
data.map((customerReport) => { data.map((customerReport) => {
@@ -803,27 +811,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
</Card> </Card>
); );
})} })}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div> </div>
{/* Filter Modal */} {/* Filter Modal */}
@@ -848,7 +835,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Icon icon='heroicons:x-mark' width={20} height={20} /> <Icon icon='heroicons:x-mark' width={20} height={20} />
</Button> </Button>
</div> </div>
<form onSubmit={formik.handleSubmit} onReset={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'>
<div> <div>
<label className='block text-xs font-semibold text-base-content py-2'> <label className='block text-xs font-semibold text-base-content py-2'>
@@ -858,18 +845,29 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<DateInput <DateInput
name='start_date' name='start_date'
value={formik.values.start_date || ''} value={formik.values.start_date || ''}
errorMessage={formik.errors.start_date}
onChange={handleStartDateChange} onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={
formik.touched.start_date &&
Boolean(formik.errors.start_date)
}
/> />
<hr className='w-full max-w-3 h-px border-base-content/10' /> <hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput <DateInput
name='end_date' name='end_date'
value={formik.values.end_date || ''} value={formik.values.end_date || ''}
errorMessage={formik.errors.end_date}
onChange={handleEndDateChange} onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isNestedModal isNestedModal
isError={hasDateError} isError={
(formik.touched.end_date &&
Boolean(formik.errors.end_date)) ||
hasDateError
}
/> />
</div> </div>
</div> </div>
@@ -878,10 +876,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Customer' label='Customer'
placeholder='Pilih Customer' placeholder='Pilih Customer'
options={customerOptions} options={customerOptions}
value={formik.values.customers} value={customerIdsValue}
onChange={(val) => onChange={(val) => {
formik.setFieldValue('customers', Array.isArray(val) ? val : []) formik.setFieldValue(
} 'customer_ids',
Array.isArray(val) && val.length > 0
? val.map((v: OptionType) => String(v.value)).join(',')
: null
);
}}
onInputChange={setCustomerInputValue} onInputChange={setCustomerInputValue}
isLoading={isLoadingCustomers} isLoading={isLoadingCustomers}
isClearable isClearable
@@ -893,15 +896,14 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
label='Filter Berdasarkan' label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan'
options={dataTypeOptions} options={dataTypeOptions}
value={formik.values.filterBy ?? null} value={filterByValue}
onChange={(val) => onChange={(val) => {
formik.setFieldValue( if (!Array.isArray(val)) {
'filterBy', formik.setFieldValue('filter_by', val?.value || null);
!Array.isArray(val) ? (val ?? undefined) : undefined }
) }}
}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isClearable isClearable={true}
/> />
</div> </div>
@@ -917,7 +919,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
<Button <Button
type='submit' type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError} disabled={hasDateError || !formik.isValid || formik.isSubmitting}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -1,7 +1,6 @@
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import Pagination from '@/components/Pagination';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import { OptionType, useSelect } from '@/components/input/SelectInput'; import { OptionType, useSelect } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal'; import Modal, { useModal } from '@/components/Modal';
@@ -9,15 +8,24 @@ import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import { SupplierApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data';
import { DebtRow, DebtSupplier } from '@/types/api/report/debt-supplier'; import {
DebtRow,
DebtSupplier,
DebtSupplierFilter,
} from '@/types/api/report/debt-supplier';
import { generateDebtSupplierExcel } from '@/components/pages/report/finance/export/DebtSupplierExportXLSX';
import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF'; import { generateDebtSupplierPDF } from '@/components/pages/report/finance/export/DebtSupllierExportPDF';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR from 'swr'; import useSWR from 'swr';
import { DebtSupplierApi } from '@/services/api/report/debt-supplier'; import { DebtSupplierApi } from '@/services/api/report/debt-supplier';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import {
DebtSupplierFilterSchema,
DebtSupplierFilterType,
} from '@/components/pages/report/finance/filter/DebtSupplierFilter';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import { Color } from '@/types/theme'; import { Color } from '@/types/theme';
import { Supplier } from '@/types/api/master-data/supplier'; import { Supplier } from '@/types/api/master-data/supplier';
@@ -26,10 +34,6 @@ import SelectInputRadio from '@/components/input/SelectInputRadio';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import StatusBadge from '@/components/helper/StatusBadge'; import StatusBadge from '@/components/helper/StatusBadge';
import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { httpClientFetcher, SWRHttpKey } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general';
import { AxiosError } from 'axios';
const dueStatus: Record<string, Color> = { const dueStatus: Record<string, Color> = {
'Sudah Jatuh Tempo': 'error', 'Sudah Jatuh Tempo': 'error',
@@ -47,6 +51,7 @@ const getPillBadge = (
statusText: string, statusText: string,
type: 'due' | 'payment' = 'payment' type: 'due' | 'payment' = 'payment'
) => { ) => {
// Get color based on type
const color = const color =
type === 'due' type === 'due'
? dueStatus[statusText] || 'neutral' ? dueStatus[statusText] || 'neutral'
@@ -63,11 +68,6 @@ const getPillBadge = (
); );
}; };
const dataTypeOptions: OptionType<string>[] = [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
];
interface DebtSupplierTabProps { interface DebtSupplierTabProps {
tabId: string; tabId: string;
} }
@@ -76,50 +76,24 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
// ===== STATE MANAGEMENT ===== // ===== STATE MANAGEMENT =====
const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isPdfExportLoading, setIsPdfExportLoading] = useState(false);
const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false);
const [isExcelGeneralExportLoading, setIsExcelGeneralExportLoading] = const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading;
useState(false);
const isAnyExportLoading =
isPdfExportLoading || isExcelExportLoading || isExcelGeneralExportLoading;
// ===== SUBMISSION STATE =====
const [filterParams, setFilterParams] = useState<DebtSupplierFilter>({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false); const [dateErrorShown, setDateErrorShown] = useState(false);
const [hasDateError, setHasDateError] = useState(false); const [hasDateError, setHasDateError] = useState(false);
const handleFilterModalOpenRef = useRef(() => {});
const filterModal = useModal(); const filterModal = useModal();
const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<{
start_date: string;
end_date: string;
suppliers: OptionType<number>[];
filterBy?: OptionType<string>;
}>({
initial: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
start_date: 'start_date',
end_date: 'end_date',
suppliers: 'supplier_ids',
filterBy: 'filter_by',
},
persist: true,
storeName: 'debt-supplier-report-table',
});
const { const {
setInputValue: setSupplierInputValue, setInputValue: setSupplierInputValue,
options: supplierOptions, options: supplierOptions,
@@ -127,180 +101,140 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
loadMore: loadMoreSuppliers, loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name'); } = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const dataTypeOptions = useMemo(
() => [
{ value: 'received_date', label: 'Tanggal Terima' },
{ value: 'po_date', label: 'Tanggal PO' },
],
[]
);
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik({ const formik = useFormik<DebtSupplierFilterType>({
initialValues: { initialValues: {
start_date: tableFilterState.start_date, startDate: null,
end_date: tableFilterState.end_date, endDate: null,
suppliers: tableFilterState.suppliers, supplierIds: null,
filterBy: tableFilterState.filterBy, filterBy: null,
}, },
validationSchema: DebtSupplierFilterSchema,
onSubmit: (values) => { onSubmit: (values) => {
updateFilter('start_date', values.start_date, true); setFilterParams({
updateFilter('end_date', values.end_date, true); start_date: values.startDate?.toString() || undefined,
updateFilter('suppliers', values.suppliers, true); end_date: values.endDate?.toString() || undefined,
updateFilter('filterBy', values.filterBy, true); supplier_ids:
values.supplierIds?.map((v) => String(v.value)).join(',') ||
undefined,
filter_by: values.filterBy?.value?.toString() || undefined,
});
filterModal.closeModal();
// setIsSubmitted(true);
},
onReset: () => {
setFilterParams({
start_date: undefined,
end_date: undefined,
supplier_ids: undefined,
filter_by: undefined,
});
// setIsSubmitted(false);
filterModal.closeModal(); filterModal.closeModal();
}, },
}); });
const formikResetHandler = () => { handleFilterModalOpenRef.current = () => {
resetFilter(); filterModal.openModal();
formik.validateForm();
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
formik.resetForm({
values: {
start_date: '',
end_date: '',
suppliers: [],
filterBy: undefined,
},
});
filterModal.closeModal();
};
// ===== DATE CHANGE HANDLERS =====
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
if (new Date(formik.values.end_date) < new Date(value)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
if (new Date(value) < new Date(formik.values.start_date)) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}; };
// ===== DATA FETCHING ===== // ===== DATA FETCHING =====
const { data: debtSupplierResponse, isLoading } = useSWR< const { data: debtSupplier, isLoading } = useSWR(
BaseApiResponse<DebtSupplier[]>, () => {
AxiosError<BaseApiResponse>, const params = {
SWRHttpKey supplier_ids: filterParams.supplier_ids,
>( filter_by: filterParams.filter_by,
`${DebtSupplierApi.basePath}/debt-supplier${getTableFilterQueryString()}`, start_date: filterParams.start_date,
httpClientFetcher end_date: filterParams.end_date,
};
return ['debt-supplier-report', params];
},
([, params]) =>
DebtSupplierApi.getDebtSupplierReport(
params.supplier_ids,
params.filter_by,
params.start_date,
params.end_date
)
); );
const data: DebtSupplier[] = isResponseSuccess(debtSupplierResponse) const data: DebtSupplier[] = useMemo(
? ((debtSupplierResponse?.data as unknown as DebtSupplier[]) ?? []) () =>
: []; isResponseSuccess(debtSupplier)
? (debtSupplier?.data as unknown as DebtSupplier[]) || []
const meta = : [],
isResponseSuccess(debtSupplierResponse) && debtSupplierResponse.meta [debtSupplier]
? debtSupplierResponse.meta );
: null;
// ===== EXPORT DATA FETCHER ===== // ===== EXPORT DATA FETCHER =====
const debtSupplierExport = useCallback(async (): Promise< const debtSupplierExport = useCallback(async (): Promise<
DebtSupplier[] | null DebtSupplier[] | null
> => { > => {
const supplier_ids = const params = {
tableFilterState.suppliers.length > 0 supplier_ids:
? tableFilterState.suppliers.map((o) => String(o.value)).join(',') formik.values.supplierIds && formik.values.supplierIds.length > 0
: undefined; ? formik.values.supplierIds.map((v) => String(v.value)).join(',')
: undefined,
filter_by: formik.values.filterBy?.value?.toString() || undefined,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
date_type: formik.values.filterBy
? formik.values.filterBy.value
: undefined,
limit: 100,
page: 1,
};
const response = await DebtSupplierApi.getDebtSupplierReport( const response = await DebtSupplierApi.getDebtSupplierReport(
supplier_ids, params.supplier_ids,
tableFilterState.filterBy?.value, params.filter_by,
tableFilterState.start_date || undefined, params.start_date,
tableFilterState.end_date || undefined, params.end_date
1,
100
); );
return isResponseSuccess(response) return isResponseSuccess(response)
? (response.data as unknown as DebtSupplier[]) ? (response.data as unknown as DebtSupplier[])
: null; : null;
}, [tableFilterState]); }, [
formik.values.supplierIds,
formik.values.startDate,
formik.values.endDate,
formik.values.filterBy,
]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const supplier_ids = const allDataForExport = await debtSupplierExport();
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',') if (
: undefined; !allDataForExport ||
await DebtSupplierApi.exportToExcelSupplierPerSheet( !Array.isArray(allDataForExport) ||
supplier_ids, allDataForExport.length === 0
tableFilterState.filterBy?.value, ) {
tableFilterState.start_date || undefined, toast.error('Tidak ada data untuk diekspor.');
tableFilterState.end_date || undefined return;
); }
generateDebtSupplierExcel({ data: allDataForExport });
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.'); toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [tableFilterState]); }, [debtSupplierExport]);
const handleExportExcelGeneral = useCallback(async () => {
setIsExcelGeneralExportLoading(true);
try {
const supplier_ids =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => String(o.value)).join(',')
: undefined;
await DebtSupplierApi.exportToExcelGeneral(
supplier_ids,
tableFilterState.filterBy?.value,
tableFilterState.start_date || undefined,
tableFilterState.end_date || undefined
);
toast.success('Excel General berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel General. Silakan coba lagi.');
} finally {
setIsExcelGeneralExportLoading(false);
}
}, [tableFilterState]);
const handleExportPdf = useCallback(async () => { const handleExportPdf = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
@@ -316,18 +250,15 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
return; return;
} }
const supplierName =
tableFilterState.suppliers.length > 0
? tableFilterState.suppliers.map((o) => o.label).join(', ')
: undefined;
await generateDebtSupplierPDF({ await generateDebtSupplierPDF({
data: allDataForExport, data: allDataForExport,
params: { params: {
supplier_name: supplierName, supplier_name: formik.values.supplierIds
filter_by: tableFilterState.filterBy?.label, ?.map((v) => v.label)
start_date: tableFilterState.start_date || undefined, .join(', '),
end_date: tableFilterState.end_date || undefined, filter_by: formik.values.filterBy?.label,
start_date: formik.values.startDate || undefined,
end_date: formik.values.endDate || undefined,
}, },
}); });
toast.success('PDF berhasil dibuat dan diunduh.'); toast.success('PDF berhasil dibuat dan diunduh.');
@@ -336,103 +267,129 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [debtSupplierExport, tableFilterState]); }, [
debtSupplierExport,
formik.values.supplierIds,
formik.values.filterBy,
formik.values.startDate,
formik.values.endDate,
]);
// ===== TAB ACTIONS ===== // ===== TAB ACTIONS COMPONENT =====
useEffect(() => { const TabActions = useMemo(() => {
setTabActions( return function TabActionsComponent() {
tabId, const setTabActions = useTabActionsStore((state) => state.setTabActions);
<div className='flex flex-row gap-3'> const clearTabActions = useTabActionsStore(
<ButtonFilter (state) => state.clearTabActions
values={{ );
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
suppliers: tableFilterState.suppliers,
filterBy: tableFilterState.filterBy,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
<Dropdown useEffect(() => {
align='end' setTabActions(
direction='bottom' tabId,
className={{ <div className='flex flex-row gap-3'>
content: <ButtonFilter
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden', values={filterParams}
}} fieldGroups={[['start_date', 'end_date']]}
trigger={ onClick={() => handleFilterModalOpenRef.current()}
<Button
variant='outline' variant='outline'
color='none' className='px-3 py-2.5'
isLoading={isAnyExportLoading} />
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
isLoading={isAnyExportLoading}
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
> >
<div className='flex flex-row items-center gap-1.5'> <Button
<Icon variant='ghost'
icon='heroicons:cloud-arrow-down' color='none'
width={20} onClick={handleExportExcel}
height={20} isLoading={isExcelExportLoading}
/> className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
<span>Export</span> >
<div className='w-px self-stretch bg-base-content/10' /> <Icon icon='heroicons:table-cells' width={20} height={20} />
<Icon icon='heroicons:chevron-down' width={14} height={14} /> Export to Excel
</div> </Button>
</Button> <Button
} variant='ghost'
> color='none'
<Button onClick={handleExportPdf}
variant='ghost' isLoading={isPdfExportLoading}
color='none' className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
onClick={handleExportExcel} >
isLoading={isExcelExportLoading} <Icon icon='heroicons:document' width={20} height={20} />
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' Export to PDF
> </Button>
<Icon icon='heroicons:table-cells' width={20} height={20} /> </Dropdown>
Export to Excel - Supplier Per Sheet </div>
</Button> );
<Button }, [setTabActions]);
variant='ghost'
color='none' useEffect(() => {
onClick={handleExportExcelGeneral} return () => {
isLoading={isExcelGeneralExportLoading} clearTabActions(tabId);
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap' };
> }, [clearTabActions]);
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel - General return null;
</Button> };
<Button
variant='ghost'
color='none'
onClick={handleExportPdf}
isLoading={isPdfExportLoading}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:document' width={20} height={20} />
Export to PDF
</Button>
</Dropdown>
</div>
);
}, [ }, [
tabId, tabId,
setTabActions, filterParams,
tableFilterState,
filterModal.openModal,
isAnyExportLoading, isAnyExportLoading,
handleExportExcel, handleExportExcel,
handleExportExcelGeneral,
handleExportPdf, handleExportPdf,
isExcelExportLoading, isExcelExportLoading,
isExcelGeneralExportLoading,
isPdfExportLoading, isPdfExportLoading,
]); ]);
const TabActionsElement = useMemo(() => <TabActions />, [TabActions]);
useEffect(() => { useEffect(() => {
return () => clearTabActions(tabId); return () => {
}, [tabId, clearTabActions]); if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [filterModal.open, dateErrorShown]);
const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [ const getTableColumns = (supplier?: DebtSupplier): ColumnDef<DebtRow>[] => [
{ {
@@ -647,9 +604,9 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
}, },
}, },
]; ];
return ( return (
<> <>
{TabActionsElement}
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'> <div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{isLoading && ( {isLoading && (
<div className='w-full flex flex-row justify-center items-center p-4'> <div className='w-full flex flex-row justify-center items-center p-4'>
@@ -673,27 +630,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
/> />
)} )}
{!isLoading && data.length > 0 && meta && (
<div className='w-full ml-auto'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
{!isLoading && {!isLoading &&
data.length > 0 && data.length > 0 &&
data.map((supplierReport) => { data.map((supplierReport) => {
@@ -781,27 +717,6 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</Card> </Card>
); );
})} })}
{!isLoading && data.length > 0 && meta && (
<div className='mt-5 px-3'>
<Pagination
totalItems={meta.total_results || 0}
itemsPerPage={meta.limit || 0}
currentPage={tableFilterState.page}
onPrevPage={() => setPage(Math.max(1, tableFilterState.page - 1))}
onNextPage={() =>
setPage(
meta.total_pages && tableFilterState.page < meta.total_pages
? tableFilterState.page + 1
: tableFilterState.page
)
}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</div>
)}
</div> </div>
{/* Filter Modal */} {/* Filter Modal */}
@@ -812,23 +727,23 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm', modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}} }}
> >
{/* Modal Header */} <form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'> {/* Modal Header */}
<div className='flex items-center gap-2 text-primary'> <div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<Icon icon='heroicons:funnel' width={20} height={20} /> <div className='flex items-center gap-2 text-primary'>
<h3 className='font-medium text-sm'>Filter Data</h3> <Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
type='button'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div> </div>
<Button
variant='link'
type='button'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <div className='p-4 flex flex-col gap-1.5'>
<div> <div>
@@ -837,68 +752,153 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => {
</label> </label>
<div className='flex flex-row gap-1.5 items-center justify-between'> <div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput <DateInput
name='start_date' name='startDate'
value={formik.values.start_date || ''} value={formik.values.startDate || ''}
onChange={handleStartDateChange} onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('startDate', value || null);
if (value && formik.values.endDate) {
const startDate = new Date(value);
const endDateObj = new Date(formik.values.endDate);
if (endDateObj < startDate) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
} else {
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}
} else {
setHasDateError(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.startDate && !!formik.errors.startDate
}
errorMessage={formik.errors.startDate}
isNestedModal isNestedModal
/> />
<hr className='w-full max-w-3 h-px border-base-content/10' /> <hr className='w-full max-w-3 h-px border-base-content/10'></hr>
<DateInput <DateInput
name='end_date' name='endDate'
value={formik.values.end_date || ''} value={formik.values.endDate || ''}
onChange={handleEndDateChange} onChange={(e) => {
const value = e.target.value;
formik.setFieldValue('endDate', value || null);
if (value && formik.values.startDate) {
const startDateObj = new Date(formik.values.startDate);
const endDate = new Date(value);
if (endDate < startDateObj) {
setHasDateError(true);
if (!dateErrorShown) {
toast.error('Tanggal akhir tidak boleh masa lampau', {
duration: Infinity,
});
setDateErrorShown(true);
}
return;
}
}
setHasDateError(false);
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
}}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
(formik.touched.endDate && !!formik.errors.endDate) ||
hasDateError
}
errorMessage={formik.errors.endDate}
isNestedModal isNestedModal
isError={hasDateError}
/> />
</div> </div>
</div> </div>
<SelectInputCheckbox <div>
label='Supplier' <SelectInputCheckbox
placeholder='Pilih Supplier' label='Supplier'
options={supplierOptions} placeholder='Pilih Supplier'
value={formik.values.suppliers} isMulti
onChange={(val) => options={supplierOptions}
formik.setFieldValue('suppliers', Array.isArray(val) ? val : []) value={
} (formik.values.supplierIds as
onInputChange={setSupplierInputValue} | { value: number; label: string }
onMenuScrollToBottom={loadMoreSuppliers} | { value: number; label: string }[]
isLoading={isLoadingSupplierOptions} | null
isClearable | undefined) || []
className={{ wrapper: 'w-full' }} }
/> onChange={(val) => {
formik.setFieldValue(
'supplierIds',
Array.isArray(val) ? val : val ? [val] : null
);
}}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isLoading={isLoadingSupplierOptions}
isClearable
className={{ wrapper: 'w-full' }}
isError={
formik.touched.supplierIds && !!formik.errors.supplierIds
}
errorMessage={formik.errors.supplierIds as string}
/>
</div>
<SelectInputRadio <div>
label='Filter Berdasarkan' <SelectInputRadio
placeholder='Pilih Filter Berdasarkan' label='Filter Berdasarkan'
options={dataTypeOptions} placeholder='Pilih Filter Berdasarkan'
value={formik.values.filterBy ?? null} options={dataTypeOptions}
onChange={(val) => value={
formik.setFieldValue( (formik.values.filterBy as
'filterBy', | { value: string; label: string }
!Array.isArray(val) ? (val ?? undefined) : undefined | { value: string; label: string }[]
) | null
} | undefined) || null
className={{ wrapper: 'w-full' }} }
isClearable onChange={(val) => {
/> formik.setFieldValue(
'filterBy',
val ? (val as OptionType) : null
);
}}
className={{ wrapper: 'w-full' }}
isClearable
isError={formik.touched.filterBy && !!formik.errors.filterBy}
errorMessage={formik.errors.filterBy as string}
/>
</div>
</div> </div>
{/* Modal Footer */} {/* Action Buttons */}
<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'
variant='soft' variant='soft'
color='none'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2' className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
type='reset'
> >
Reset Filter Reset Filter
</Button> </Button>
<Button <Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold' className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={hasDateError} type='submit'
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -156,17 +156,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
}); });
handleFilterModalOpenRef.current = () => { handleFilterModalOpenRef.current = () => {
formik.setValues({
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
area_ids: filterParams.area_id || null,
supplier_ids: filterParams.supplier_id || null,
product_ids: filterParams.product_id || null,
product_category_ids: filterParams.product_category_id || null,
filter_by: filterParams.filter_by || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const { setFieldValue } = formik; const { setFieldValue } = formik;
@@ -1,8 +1,6 @@
import * as yup from 'yup'; import * as yup from 'yup';
export type DailyMarketingReportFilterType = { export type DailyMarketingReportFilterType = {
page?: number;
pageSize?: number;
search: string | null; search: string | null;
area_id: string | null; area_id: string | null;
location_id: string | null; location_id: string | null;
@@ -16,8 +14,6 @@ export type DailyMarketingReportFilterType = {
}; };
export const DailyMarketingReportFilterSchema = yup.object({ export const DailyMarketingReportFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
search: yup.string().nullable(), search: yup.string().nullable(),
area_id: yup.string().nullable(), area_id: yup.string().nullable(),
location_id: yup.string().nullable(), location_id: yup.string().nullable(),
@@ -1,8 +1,6 @@
import * as yup from 'yup'; import * as yup from 'yup';
export type HppPerKandangFilterType = { export type HppPerKandangFilterType = {
page?: number;
pageSize?: number;
area_id: string | null; area_id: string | null;
location_id: string | null; location_id: string | null;
kandang_id: string | null; kandang_id: string | null;
@@ -14,8 +12,6 @@ export type HppPerKandangFilterType = {
}; };
export const HppPerKandangFilterSchema = yup.object({ export const HppPerKandangFilterSchema = yup.object({
page: yup.number().nullable(),
pageSize: yup.number().nullable(),
area_id: yup.string().nullable(), area_id: yup.string().nullable(),
location_id: yup.string().nullable(), location_id: yup.string().nullable(),
kandang_id: yup.string().nullable(), kandang_id: yup.string().nullable(),
@@ -17,10 +17,16 @@ import {
formatVechicleNumber, formatVechicleNumber,
formatTitleCase, formatTitleCase,
} from '@/lib/helper'; } from '@/lib/helper';
import { DailyMarketingRow } from '@/types/api/report/marketing'; import {
DailyMarketingRow,
DailyMarketingReportResponse,
} from '@/types/api/report/marketing';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Dropdown from '@/components/Dropdown'; import Dropdown from '@/components/Dropdown';
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
import { pdf } from '@react-pdf/renderer';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
@@ -33,6 +39,8 @@ import Modal, { useModal } from '@/components/Modal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton'; import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
import { useEffect as useEffectHook } from 'react'; import { useEffect as useEffectHook } from 'react';
import { httpClient } from '@/services/http/client';
import { isResponseError } from '@/lib/api-helper';
import { import {
MARKETING_DATE_FILTER_TYPE_OPTIONS, MARKETING_DATE_FILTER_TYPE_OPTIONS,
MARKETING_TYPE_OPTIONS, MARKETING_TYPE_OPTIONS,
@@ -45,8 +53,6 @@ interface DailyMarketingTabProps {
} }
interface FilterParams { interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string; area_id?: string;
location_id?: string; location_id?: string;
warehouse_id?: string; warehouse_id?: string;
@@ -110,8 +116,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<DailyMarketingReportFilterType>({ const formik = useFormik<DailyMarketingReportFilterType>({
initialValues: { initialValues: {
page: 1,
pageSize: 10,
search: null, search: null,
area_id: null, area_id: null,
location_id: null, location_id: null,
@@ -126,8 +130,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
validationSchema: DailyMarketingReportFilterSchema, validationSchema: DailyMarketingReportFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
setFilterParams({ setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined, area_id: values.area_id || undefined,
location_id: values.location_id || undefined, location_id: values.location_id || undefined,
warehouse_id: values.warehouse_id || undefined, warehouse_id: values.warehouse_id || undefined,
@@ -148,21 +150,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
}); });
handleFilterModalOpenRef.current = () => { handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
search: formik.values.search,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
warehouse_id: filterParams.warehouse_id || null,
customer_id: filterParams.customer_id || null,
start_date: filterParams.start_date || null,
end_date: filterParams.end_date || null,
filter_by: filterParams.filter_by || null,
marketing_type: filterParams.marketing_type || null,
sort_by: filterParams.sort_by || null,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
// ===== SEARCH CHANGE HANDLER ===== // ===== SEARCH CHANGE HANDLER =====
@@ -233,9 +222,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue); if (searchValue) params.set('search', searchValue);
if (filterParams.page) params.set('page', String(filterParams.page));
if (filterParams.pageSize)
params.set('limit', String(filterParams.pageSize));
if (filterParams.area_id) params.set('area_id', filterParams.area_id); if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id) if (filterParams.location_id)
params.set('location_id', filterParams.location_id); params.set('location_id', filterParams.location_id);
@@ -276,30 +262,67 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
[dailyMarketings] [dailyMarketings]
); );
// ===== EXPORT DATA FETCHER =====
const dailyMarketingsExport = useCallback(async (): Promise<
DailyMarketingRow[] | null
> => {
const params = new URLSearchParams();
if (searchValue) params.set('search', searchValue);
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
if (filterParams.location_id)
params.set('location_id', filterParams.location_id);
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
params.set('limit', '9999999');
const queryString = `?${params.toString()}`;
try {
const response = await httpClient<DailyMarketingReportResponse>(
`${MarketingReportApi.basePath}${queryString}`
);
if (isResponseError(response)) {
return null;
}
return response.data || [];
} catch {
return null;
}
}, [filterParams, searchValue]);
// ===== EXPORT HANDLERS ===== // ===== EXPORT HANDLERS =====
const handleExportExcel = useCallback(async () => { const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true); setIsExcelExportLoading(true);
try { try {
const params = new URLSearchParams(); const allDataForExport = await dailyMarketingsExport();
if (searchValue) params.set('search', searchValue); if (!allDataForExport || allDataForExport.length === 0) {
if (filterParams.area_id) params.set('area_id', filterParams.area_id); toast.error('Tidak ada data untuk diekspor.');
if (filterParams.location_id) return;
params.set('location_id', filterParams.location_id); }
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
await MarketingReportApi.exportDailyMarketingToExcel(params.toString()); const period =
filterParams.start_date && filterParams.end_date
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
: undefined;
await generateDailyMarketingExcel({
data: allDataForExport,
summaryTotal: summaryTotal,
period: period,
});
toast.success('Excel berhasil dibuat dan diunduh.'); toast.success('Excel berhasil dibuat dan diunduh.');
} catch { } catch {
@@ -307,39 +330,34 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
} finally { } finally {
setIsExcelExportLoading(false); setIsExcelExportLoading(false);
} }
}, [filterParams, searchValue]); }, [filterParams, dailyMarketingsExport, summaryTotal]);
const handleExportPDF = useCallback(async () => { const handleExportPDF = useCallback(async () => {
setIsPdfExportLoading(true); setIsPdfExportLoading(true);
try { try {
const params = new URLSearchParams(); const allDataForExport = await dailyMarketingsExport();
if (searchValue) params.set('search', searchValue); if (!allDataForExport || allDataForExport.length === 0) {
if (filterParams.area_id) params.set('area_id', filterParams.area_id); toast.error('Tidak ada data untuk diekspor.');
if (filterParams.location_id) return;
params.set('location_id', filterParams.location_id); }
if (filterParams.warehouse_id)
params.set('warehouse_id', filterParams.warehouse_id);
if (filterParams.customer_id)
params.set('customer_id', filterParams.customer_id);
if (filterParams.start_date)
params.set('start_date', filterParams.start_date);
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
if (filterParams.filter_by)
params.set('filter_by', filterParams.filter_by);
if (filterParams.marketing_type)
params.set('marketing_type', filterParams.marketing_type);
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
await MarketingReportApi.exportDailyMarketingToPDF(params.toString()); const dailyMarketingReportPdfBlob = await pdf(
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
).toBlob();
toast.success('PDF berhasil dibuat dan diunduh.'); const dailyMarketingReportPdfUrl = URL.createObjectURL(
dailyMarketingReportPdfBlob
);
window.open(dailyMarketingReportPdfUrl, '_blank');
toast.success('PDF berhasil dibuat.');
} catch { } catch {
toast.error('Gagal membuat PDF. Silakan coba lagi.'); toast.error('Gagal membuat PDF. Silakan coba lagi.');
} finally { } finally {
setIsPdfExportLoading(false); setIsPdfExportLoading(false);
} }
}, [filterParams, searchValue]); }, [dailyMarketingsExport, summaryTotal]);
// ===== TAB ACTIONS COMPONENT ===== // ===== TAB ACTIONS COMPONENT =====
const TabActions = useMemo(() => { const TabActions = useMemo(() => {
@@ -554,7 +572,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'qty', accessorKey: 'qty',
cell: (props) => formatNumber(props.row.original.qty), cell: (props) => formatNumber(props.row.original.qty),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_qty {summaryTotal?.total_qty
? formatNumber(summaryTotal.total_qty) ? formatNumber(summaryTotal.total_qty)
: '-'} : '-'}
@@ -567,7 +585,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'average_weight_kg', accessorKey: 'average_weight_kg',
cell: (props) => formatNumber(props.row.original.average_weight_kg), cell: (props) => formatNumber(props.row.original.average_weight_kg),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.average_weight_kg {summaryTotal?.average_weight_kg
? formatNumber(summaryTotal.average_weight_kg) ? formatNumber(summaryTotal.average_weight_kg)
: '-'} : '-'}
@@ -580,7 +598,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'total_weight_kg', accessorKey: 'total_weight_kg',
cell: (props) => formatNumber(props.row.original.total_weight_kg), cell: (props) => formatNumber(props.row.original.total_weight_kg),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_weight_kg {summaryTotal?.total_weight_kg
? formatNumber(summaryTotal.total_weight_kg) ? formatNumber(summaryTotal.total_weight_kg)
: '-'} : '-'}
@@ -593,9 +611,9 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_price_per_kg', accessorKey: 'sales_price_per_kg',
cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), cell: (props) => formatCurrency(props.row.original.sales_price_per_kg),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.average_sales_price {summaryTotal?.average_sales_price
? formatCurrency(summaryTotal.average_sales_price) ? formatNumber(summaryTotal.average_sales_price)
: '-'} : '-'}
</div> </div>
), ),
@@ -606,7 +624,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'hpp_price_per_kg', accessorKey: 'hpp_price_per_kg',
cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_hpp_price_per_kg {summaryTotal?.total_hpp_price_per_kg
? formatCurrency(summaryTotal.total_hpp_price_per_kg) ? formatCurrency(summaryTotal.total_hpp_price_per_kg)
: '-'} : '-'}
@@ -619,7 +637,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
accessorKey: 'sales_amount', accessorKey: 'sales_amount',
cell: (props) => formatCurrency(props.row.original.sales_amount), cell: (props) => formatCurrency(props.row.original.sales_amount),
footer: () => ( footer: () => (
<div className='font-semibold text-gray-900'> <div className='text-right font-semibold text-gray-900'>
{summaryTotal?.total_sales_amount {summaryTotal?.total_sales_amount
? formatCurrency(summaryTotal.total_sales_amount) ? formatCurrency(summaryTotal.total_sales_amount)
: '-'} : '-'}
@@ -670,27 +688,6 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
<Table <Table
data={data} data={data}
columns={getTableColumns()} columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.page
: 0
}
totalItems={
isResponseSuccess(dailyMarketings)
? dailyMarketings?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0} renderFooter={data.length > 0}
className={{ className={{
containerClassName: 'w-full mb-0!', containerClassName: 'w-full mb-0!',
@@ -40,8 +40,6 @@ interface HppPerKandangTabProps {
} }
interface FilterParams { interface FilterParams {
page?: number;
pageSize?: number;
area_id?: string; area_id?: string;
location_id?: string; location_id?: string;
kandang_id?: string; kandang_id?: string;
@@ -110,8 +108,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
// ===== FORMIK SETUP ===== // ===== FORMIK SETUP =====
const formik = useFormik<HppPerKandangFilterType>({ const formik = useFormik<HppPerKandangFilterType>({
initialValues: { initialValues: {
page: 1,
pageSize: 10,
area_id: null, area_id: null,
location_id: null, location_id: null,
kandang_id: null, kandang_id: null,
@@ -124,8 +120,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
validationSchema: HppPerKandangFilterSchema, validationSchema: HppPerKandangFilterSchema,
onSubmit: (values, { setSubmitting }) => { onSubmit: (values, { setSubmitting }) => {
setFilterParams({ setFilterParams({
page: values.page || undefined,
pageSize: values.pageSize || undefined,
area_id: values.area_id || undefined, area_id: values.area_id || undefined,
location_id: values.location_id || undefined, location_id: values.location_id || undefined,
kandang_id: values.kandang_id || undefined, kandang_id: values.kandang_id || undefined,
@@ -152,19 +146,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
}); });
handleFilterModalOpenRef.current = () => { handleFilterModalOpenRef.current = () => {
formik.setValues({
page: formik.values.page,
pageSize: formik.values.pageSize,
area_id: filterParams.area_id || null,
location_id: filterParams.location_id || null,
kandang_id: filterParams.kandang_id || null,
weight_min: filterParams.weight_min || null,
weight_max: filterParams.weight_max || null,
period: filterParams.period || null,
sort_by: filterParams.sort_by || null,
show_unrecorded: filterParams.show_unrecorded ?? false,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
// ===== WEIGHT CHANGE HANDLERS ===== // ===== WEIGHT CHANGE HANDLERS =====
@@ -274,8 +257,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
period: filterParams.period, period: filterParams.period,
sort_by: filterParams.sort_by, sort_by: filterParams.sort_by,
show_unrecorded: filterParams.show_unrecorded, show_unrecorded: filterParams.show_unrecorded,
page: filterParams.page,
pageSize: filterParams.pageSize,
}; };
return ['hpp-per-kandang-report', params]; return ['hpp-per-kandang-report', params];
@@ -290,9 +271,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max, params.weight_max,
params.period, params.period,
params.sort_by, params.sort_by,
params.show_unrecorded, params.show_unrecorded
params.page,
params.pageSize
) )
); );
@@ -342,9 +321,7 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
params.weight_max, params.weight_max,
params.period, params.period,
params.sort_by, params.sort_by,
params.show_unrecorded, params.show_unrecorded
params.page,
params.limit
); );
return isResponseSuccess(response) ? response.data : null; return isResponseSuccess(response) ? response.data : null;
@@ -489,7 +466,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<div className='flex flex-row gap-3'> <div className='flex flex-row gap-3'>
<ButtonFilter <ButtonFilter
values={filterParams} values={filterParams}
excludeFields={['page', 'pageSize']}
onClick={() => handleFilterModalOpenRef.current()} onClick={() => handleFilterModalOpenRef.current()}
variant='outline' variant='outline'
className='px-3 py-2.5' className='px-3 py-2.5'
@@ -869,25 +845,6 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => {
<Table <Table
data={data} data={data}
columns={getTableColumns()} columns={getTableColumns()}
pageSize={filterParams.pageSize}
page={
isResponseSuccess(hppPerKandang) ? hppPerKandang?.meta?.page : 0
}
totalItems={
isResponseSuccess(hppPerKandang)
? hppPerKandang?.meta?.total_results
: 0
}
onPageChange={(newPage) =>
setFilterParams((prevVal) => ({ ...prevVal, page: newPage }))
}
onPageSizeChange={(newPageSize) =>
setFilterParams((prevVal) => ({
...prevVal,
pageSize: newPageSize,
}))
}
isLoading={isLoading}
renderFooter={data.length > 0} renderFooter={data.length > 0}
renderCustomRow={renderCustomRow} renderCustomRow={renderCustomRow}
className={{ className={{
@@ -263,43 +263,8 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => {
}); });
handleFilterModalOpenRef.current = () => { handleFilterModalOpenRef.current = () => {
const restoredAreaId = filterParams.area_id
? areaOptions.find(
(opt) => String(opt.value) === filterParams.area_id
) || { value: filterParams.area_id, label: filterParams.area_id }
: null;
const restoredLocationId = filterParams.location_id
? locationOptions.find(
(opt) => String(opt.value) === filterParams.location_id
) || {
value: filterParams.location_id,
label: filterParams.location_id,
}
: null;
const restoredProjectFlockId = filterParams.project_flock_id
? projectFlockOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_id
) || {
value: filterParams.project_flock_id,
label: filterParams.project_flock_id,
}
: null;
const restoredKandangId = filterParams.project_flock_kandang_id
? projectFlockKandangOptions.find(
(opt) => String(opt.value) === filterParams.project_flock_kandang_id
) || {
value: filterParams.project_flock_kandang_id,
label: filterParams.project_flock_kandang_id,
}
: null;
formik.setValues({
area_id: restoredAreaId,
location_id: restoredLocationId,
project_flock_id: restoredProjectFlockId,
kandang_id: restoredKandangId,
});
filterModal.openModal(); filterModal.openModal();
formik.validateForm();
}; };
const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] = const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] =
+1 -11
View File
@@ -197,7 +197,6 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
icon: 'heroicons-outline:folder', icon: 'heroicons-outline:folder',
permission: [ permission: [
'lti.inventory.product_stock.list', 'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
'lti.inventory.product_warehouses.list', 'lti.inventory.product_warehouses.list',
'lti.inventory.transfer.list', 'lti.inventory.transfer.list',
], ],
@@ -205,10 +204,7 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
{ {
text: 'Stok Produk', text: 'Stok Produk',
link: '/inventory/product', link: '/inventory/product',
permission: [ permission: ['lti.inventory.product_stock.list'],
'lti.inventory.product_stock.list',
'lti.inventory.stock_log.list',
],
}, },
{ {
text: 'Penyesuaian Stok', text: 'Penyesuaian Stok',
@@ -240,7 +236,6 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
'lti.master.uoms.list', 'lti.master.uoms.list',
'lti.master.warehouses.list', 'lti.master.warehouses.list',
'lti.master.production_standards.list', 'lti.master.production_standards.list',
'lti.system_settings.update',
], ],
submenu: [ submenu: [
{ {
@@ -308,11 +303,6 @@ export const MAIN_DRAWER_LINKS: SidebarMenuItem[] = [
link: '/master-data/production-standard', link: '/master-data/production-standard',
permission: ['lti.master.production_standards.list'], permission: ['lti.master.production_standards.list'],
}, },
{
text: 'Konfigurasi Sistem',
link: '/master-data/system-config',
permission: ['lti.system_settings.update'],
},
], ],
}, },
] as const; ] as const;
-2
View File
@@ -218,6 +218,4 @@ export const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/master-data/production-standard/detail/edit/': [ '/master-data/production-standard/detail/edit/': [
'lti.master.production_standards.update', 'lti.master.production_standards.update',
], ],
'/master-data/system-config/': ['lti.system_settings.update'],
}; };
@@ -20,7 +20,6 @@ interface DatePickerProps {
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
formatDisplay?: (date: string) => string; formatDisplay?: (date: string) => string;
hasError?: boolean;
} }
export function DatePicker({ export function DatePicker({
@@ -29,7 +28,6 @@ export function DatePicker({
disabled = false, disabled = false,
placeholder = 'Select date', placeholder = 'Select date',
formatDisplay, formatDisplay,
hasError = false,
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => { const [currentMonth, setCurrentMonth] = useState(() => {
@@ -156,7 +154,7 @@ export function DatePicker({
<Button <Button
variant='outline' variant='outline'
disabled={disabled} disabled={disabled}
className={`w-full justify-start text-left font-normal hover:bg-gray-50 ${hasError ? 'border-red-500 focus:ring-red-500' : 'border-gray-200'}`} className='w-full justify-start text-left font-normal border-gray-200 hover:bg-gray-50'
> >
<CalendarIcon className='mr-2 h-4 w-4 text-gray-500' /> <CalendarIcon className='mr-2 h-4 w-4 text-gray-500' />
{date ? ( {date ? (
File diff suppressed because it is too large Load Diff
@@ -89,10 +89,7 @@ export function Dashboard() {
options: kandangOptions, options: kandangOptions,
loadMore: loadMoreKandang, loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang, isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', { } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
order_by: 'asc',
sort_by: 'name',
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
@@ -40,12 +40,11 @@ import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist'; import { DailyChecklist } from '@/types/api/daily-checklist/daily-checklist';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import { ColumnDef, Row } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput'; import { useSelect } from '@/components/input/SelectInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import RequirePermission from '@/components/helper/RequirePermission'; import RequirePermission from '@/components/helper/RequirePermission';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang'; import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
import CheckboxInput from '@/components/input/CheckboxInput';
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: 'ALL', label: 'Semua Status' }, { value: 'ALL', label: 'Semua Status' },
@@ -60,7 +59,6 @@ const CATEGORY_LABELS: { [key: string]: string } = {
pullet_close: 'Pullet Close', pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open', produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close', produksi_close: 'Produksi Close',
empty_kandang: 'Kandang Kosong',
}; };
export function ListDailyChecklistContent() { export function ListDailyChecklistContent() {
@@ -89,9 +87,6 @@ export function ListDailyChecklistContent() {
date_from: 'date_from', date_from: 'date_from',
date_to: 'date_to', date_to: 'date_to',
}, },
persist: true,
storeName: 'list-daily-checklist-content-table',
}); });
const { const {
@@ -110,10 +105,7 @@ export function ListDailyChecklistContent() {
options: kandangOptions, options: kandangOptions,
isLoadingMore: isLoadingMoreKandang, isLoadingMore: isLoadingMoreKandang,
loadMore: loadMoreKandang, loadMore: loadMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', { } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
order_by: 'asc',
sort_by: 'name',
});
const checklistList = isResponseSuccess(checklistListRes) const checklistList = isResponseSuccess(checklistListRes)
? checklistListRes.data || [] ? checklistListRes.data || []
@@ -130,29 +122,12 @@ export function ListDailyChecklistContent() {
// Modals // Modals
const [showApproveModal, setShowApproveModal] = useState(false); const [showApproveModal, setShowApproveModal] = useState(false);
const [showBulkApproveModal, setShowBulkApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false); const [showRejectModal, setShowRejectModal] = useState(false);
const [showBulkRejectModal, setShowBulkRejectModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null); const [selectedItem, setSelectedItem] = useState<DailyChecklist | null>(null);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection);
const selectedRowItems = selectedRowIds.map((itemId) =>
checklistList.find((item) => item.id === parseInt(itemId))
);
const tableEnableRowSelectionHandler: (
row: Row<DailyChecklist>
) => boolean = (row) => {
return (
row.original.status !== 'APPROVED' && row.original.status !== 'REJECTED'
);
};
const handleDetail = (item: DailyChecklist) => { const handleDetail = (item: DailyChecklist) => {
router.push( router.push(
`/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}` `/daily-checklist/list-daily-checklist/detail?checklistId=${item.id}`
@@ -160,7 +135,13 @@ export function ListDailyChecklistContent() {
}; };
const handleEdit = (item: DailyChecklist) => { const handleEdit = (item: DailyChecklist) => {
router.push(`/daily-checklist/daily-checklist?checklistId=${item.id}`); const formattedDate = new Date(item.date).toISOString().split('T')[0];
const kandangId = item.kandang?.id ?? '';
const category = item.category;
router.push(
`/daily-checklist/daily-checklist?date=${formattedDate}&kandang_id=${kandangId}&category=${category}`
);
}; };
const handleApprove = (item: DailyChecklist) => { const handleApprove = (item: DailyChecklist) => {
@@ -168,22 +149,21 @@ export function ListDailyChecklistContent() {
setShowApproveModal(true); setShowApproveModal(true);
}; };
const handleBulkApprove = () => {
setShowBulkApproveModal(true);
};
const handleReject = (item: DailyChecklist) => { const handleReject = (item: DailyChecklist) => {
setSelectedItem(item); setSelectedItem(item);
setRejectReason(''); setRejectReason('');
setShowRejectModal(true); setShowRejectModal(true);
}; };
const handleBulkReject = () => {
setRejectReason('');
setShowBulkRejectModal(true);
};
const handleDelete = (item: DailyChecklist) => { const handleDelete = (item: DailyChecklist) => {
// ✅ VALIDATION: Only DRAFT can be deleted
if (item.status !== 'DRAFT') {
toast.error('Hanya checklist dengan status DRAFT yang bisa dihapus', {
description: `Status saat ini: ${item.status}`,
});
return;
}
setSelectedItem(item); setSelectedItem(item);
setShowDeleteModal(true); setShowDeleteModal(true);
}; };
@@ -215,31 +195,6 @@ export function ListDailyChecklistContent() {
} }
}; };
const confirmBulkApprove = async () => {
if (!selectedRowIds.length) return;
try {
setActionLoading(true);
const approveRes = await DailyChecklistApi.bulkApprove(selectedRowIds);
if (isResponseError(approveRes)) {
toast.error('Gagal approve checklist: ' + approveRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-approve');
setShowBulkApproveModal(false);
setRowSelection({});
} catch (error) {
console.error('Error approving checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmReject = async () => { const confirmReject = async () => {
if (!selectedItem) return; if (!selectedItem) return;
@@ -274,40 +229,6 @@ export function ListDailyChecklistContent() {
} }
}; };
const confirmBulkReject = async () => {
if (!selectedRowIds.length) return;
if (!rejectReason.trim()) {
toast.error('Alasan reject harus diisi');
return;
}
try {
setActionLoading(true);
const rejectRes = await DailyChecklistApi.bulkReject(
selectedRowIds,
rejectReason
);
if (isResponseError(rejectRes)) {
toast.error('Gagal reject checklist: ' + rejectRes.message);
return;
}
refreshChecklistList();
toast.success('Checklist berhasil di-reject');
setShowBulkRejectModal(false);
setRowSelection({});
setRejectReason('');
} catch (error) {
console.error('Error rejecting checklist:', error);
toast.error('Terjadi kesalahan');
} finally {
setActionLoading(false);
}
};
const confirmDelete = async () => { const confirmDelete = async () => {
if (!selectedItem) return; if (!selectedItem) return;
@@ -404,37 +325,6 @@ export function ListDailyChecklistContent() {
}; };
const checklistListColumns: ColumnDef<DailyChecklist>[] = [ const checklistListColumns: ColumnDef<DailyChecklist>[] = [
{
id: 'select',
header: ({ table }) => (
<div className='w-full flex flex-row justify-center'>
<CheckboxInput
name='allRow'
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
</div>
),
cell: ({ row }) => {
const isCheckboxDisabled =
!row.getCanSelect() ||
row.original.status === 'APPROVED' ||
row.original.status === 'REJECTED';
return (
<div>
<CheckboxInput
name='row'
checked={row.getIsSelected()}
disabled={isCheckboxDisabled}
indeterminate={row.getIsSomeSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</div>
);
},
},
{ {
accessorKey: 'date', accessorKey: 'date',
header: 'Tanggal', header: 'Tanggal',
@@ -547,17 +437,19 @@ export function ListDailyChecklistContent() {
</RequirePermission> </RequirePermission>
)} )}
<RequirePermission permissions='lti.daily_checklist.create'> {row.original.status === 'DRAFT' && (
<Button <RequirePermission permissions='lti.daily_checklist.create'>
size='sm' <Button
variant='destructive' size='sm'
onClick={() => handleDelete(row.original)} variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white' onClick={() => handleDelete(row.original)}
> className='bg-red-600 hover:bg-red-700 text-white'
<Trash2 className='w-4 h-4 mr-1' /> >
Hapus <Trash2 className='w-4 h-4 mr-1' />
</Button> Hapus
</RequirePermission> </Button>
</RequirePermission>
)}
</div> </div>
), ),
}, },
@@ -567,39 +459,13 @@ export function ListDailyChecklistContent() {
<div className='min-h-screen'> <div className='min-h-screen'>
<div className='p-6'> <div className='p-6'>
{/* Page Title */} {/* Page Title */}
<div className='mb-6 flex flex-row justify-between items-center gap-3'> <div className='mb-6'>
<div> <h1 className='text-2xl font-semibold text-gray-900'>
<h1 className='text-2xl font-semibold text-gray-900'> List Daily Checklist
List Daily Checklist </h1>
</h1> <p className='text-sm text-gray-600 mt-1'>
<p className='text-sm text-gray-600 mt-1'> Daftar semua checklist harian
Daftar semua checklist harian </p>
</p>
</div>
<RequirePermission permissions='lti.daily_checklist.create'>
{selectedRowIds.length > 0 && (
<div className='flex flex-row items-center gap-3'>
<Button
size='sm'
onClick={handleBulkApprove}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-1' />
Bulk Approve {`(${selectedRowIds.length}) item`}
</Button>
<Button
size='sm'
variant='destructive'
onClick={handleBulkReject}
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-1' />
Bulk Reject {`(${selectedRowIds.length}) item`}
</Button>
</div>
)}
</RequirePermission>
</div> </div>
{/* Main Card */} {/* Main Card */}
@@ -722,10 +588,6 @@ export function ListDailyChecklistContent() {
} }
onPageChange={setPage} onPageChange={setPage}
isLoading={isLoadingChecklistList} isLoading={isLoadingChecklistList}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
enableRowSelection={tableEnableRowSelectionHandler}
withCheckbox
className={{ className={{
containerClassName: cn({ containerClassName: cn({
'w-full mb-20': 'w-full mb-20':
@@ -804,76 +666,6 @@ export function ListDailyChecklistContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Bulk Approve Modal */}
<Dialog
open={showBulkApproveModal}
onOpenChange={setShowBulkApproveModal}
>
<DialogContent className='sm:max-w-md max-h-[80vh] overflow-y-auto bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Approve Checklist</DialogTitle>
<DialogDescription>
Apakah Anda yakin ingin approve {selectedRowIds.length} checklist
ini?
</DialogDescription>
</DialogHeader>
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
{selectedRowItems.map((item) => (
<div
key={item?.id ?? 0}
className='bg-gray-50 rounded-lg p-4 space-y-2'
>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(item?.date ?? '')}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{item?.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{item?.category
? (CATEGORY_LABELS[item.category] ?? item?.category)
: item?.category}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Progress:</span>
<span className='font-medium text-gray-900'>
{item?.progress}%
</span>
</div>
</div>
))}
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowBulkApproveModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmBulkApprove}
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Approve'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Modal */} {/* Reject Modal */}
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}> <Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'> <DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
@@ -943,81 +735,6 @@ export function ListDailyChecklistContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Bulk Reject Modal */}
<Dialog open={showBulkRejectModal} onOpenChange={setShowBulkRejectModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
<DialogHeader>
<DialogTitle>Reject Checklist</DialogTitle>
<DialogDescription>
Berikan alasan reject untuk checklist ini
</DialogDescription>
</DialogHeader>
<div className='max-h-[60vh] overflow-y-auto flex flex-col gap-3'>
{selectedRowItems.map((item) => (
<div
key={item?.id ?? 0}
className='bg-gray-50 rounded-lg p-4 space-y-2 mb-4'
>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Tanggal:</span>
<span className='font-medium text-gray-900'>
{formatDate(item?.date ?? '')}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kandang:</span>
<span className='font-medium text-gray-900'>
{item?.kandang?.name ?? '-'}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Kategori:</span>
<span className='font-medium text-gray-900'>
{item?.category
? CATEGORY_LABELS[item.category] || item?.category
: item?.category}
</span>
</div>
</div>
))}
</div>
<div>
<Label htmlFor='reject-reason'>
Alasan Reject <span className='text-red-500'>*</span>
</Label>
<Textarea
id='reject-reason'
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder='Tuliskan alasan reject...'
className='mt-1.5 border-gray-200 min-h-[100px]'
disabled={actionLoading}
/>
</div>
<DialogFooter className='flex gap-2'>
<Button
variant='outline'
onClick={() => setShowBulkRejectModal(false)}
disabled={actionLoading}
className='border-gray-200'
>
Batal
</Button>
<Button
onClick={confirmBulkReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
{actionLoading ? 'Memproses...' : 'Ya, Reject'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Modal */} {/* Delete Modal */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}> <Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'> <DialogContent className='sm:max-w-md bg-white rounded-xl shadow-lg'>
@@ -2,14 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import * as React from 'react'; import * as React from 'react';
import { import { ArrowLeft, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
ArrowLeft,
CheckCircle,
XCircle,
AlertCircle,
Share2,
} from 'lucide-react';
import * as htmlToImage from 'html-to-image';
import { Card, CardContent } from '@/figma-make/components/base/card'; import { Card, CardContent } from '@/figma-make/components/base/card';
import { Button } from '@/figma-make/components/base/button'; import { Button } from '@/figma-make/components/base/button';
import { Badge } from '@/figma-make/components/base/badge'; import { Badge } from '@/figma-make/components/base/badge';
@@ -60,7 +53,6 @@ interface ChecklistHeader {
progress_percent: number; progress_percent: number;
total_phases: number; total_phases: number;
total_activities: number; total_activities: number;
empty_kandang_end_date?: string | null;
} }
interface PhaseGroup { interface PhaseGroup {
@@ -114,7 +106,6 @@ const CATEGORY_LABELS: { [key: string]: string } = {
pullet_close: 'Pullet Close', pullet_close: 'Pullet Close',
produksi_open: 'Produksi Open', produksi_open: 'Produksi Open',
produksi_close: 'Produksi Close', produksi_close: 'Produksi Close',
empty_kandang: 'Kandang Kosong',
}; };
const TIME_TYPE_ORDER = ['Umum', 'Pagi', 'Siang', 'Sore', 'Malam']; const TIME_TYPE_ORDER = ['Umum', 'Pagi', 'Siang', 'Sore', 'Malam'];
@@ -146,8 +137,6 @@ export function DetailDailyChecklistContent() {
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
useEffect(() => { useEffect(() => {
if (checklistId) { if (checklistId) {
fetchChecklistDetail(); fetchChecklistDetail();
@@ -180,9 +169,6 @@ export function DetailDailyChecklistContent() {
setDocuments(rawDetailChecklist?.document_urls || []); setDocuments(rawDetailChecklist?.document_urls || []);
const emptyKandangEndDate =
rawDetailChecklist?.empty_kandang?.end_date ?? null;
const checklistData = { const checklistData = {
id: rawDetailChecklist?.id, id: rawDetailChecklist?.id,
date: rawDetailChecklist?.date, date: rawDetailChecklist?.date,
@@ -209,7 +195,6 @@ export function DetailDailyChecklistContent() {
progress_percent: 0, progress_percent: 0,
total_phases: 0, total_phases: 0,
total_activities: 0, total_activities: 0,
empty_kandang_end_date: emptyKandangEndDate,
}); });
setLoading(false); setLoading(false);
return; return;
@@ -277,7 +262,6 @@ export function DetailDailyChecklistContent() {
progress_percent: 0, progress_percent: 0,
total_phases: new Set(tasks.map((t) => t.phase_id)).size, total_phases: new Set(tasks.map((t) => t.phase_id)).size,
total_activities: tasks.length, total_activities: tasks.length,
empty_kandang_end_date: emptyKandangEndDate,
}); });
setLoading(false); setLoading(false);
return; return;
@@ -328,7 +312,6 @@ export function DetailDailyChecklistContent() {
progress_percent: progressPercent, progress_percent: progressPercent,
total_phases: uniquePhases.size, total_phases: uniquePhases.size,
total_activities: uniqueActivities.size, total_activities: uniqueActivities.size,
empty_kandang_end_date: emptyKandangEndDate,
}); });
} catch (error) { } catch (error) {
console.error('Error fetching checklist detail:', error); console.error('Error fetching checklist detail:', error);
@@ -564,103 +547,6 @@ export function DetailDailyChecklistContent() {
}); });
}; };
const isMobileDevice = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
};
const getStatusMessage = () => {
switch (header?.status) {
case 'DRAFT':
return 'Checklist harian perlu disubmit';
case 'SUBMITTED':
return 'Checklist harian menunggu persetujuan';
case 'APPROVED':
return 'Checklist harian telah disetujui';
case 'REJECTED':
return 'Checklist harian telah ditolak';
default:
return '';
}
};
const shareHandler = async () => {
const isMobile = isMobileDevice();
if (isMobile) {
setIsGeneratingImage(true);
}
const baseTitle = `Daily Checklist - ${formatDate(header?.date || '')} - ${header?.kandang_name} - ${header?.category}`;
const statusMsg = getStatusMessage();
const statusInfo = `\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}`;
const urlMessage = `\n\nView full checklist: ${window.location.href}`;
const fullMessage = baseTitle + statusInfo + urlMessage;
let shareData: ShareData;
if (isMobile) {
const htmlBlob = await htmlToImage.toBlob(document.body, {
backgroundColor: '#ffffff',
});
const imgFile = new File(
[htmlBlob!],
`daily-checklist-${header?.date}-${header?.kandang_name}-${header?.category}.png`,
{
type: 'image/png',
}
);
shareData = {
files: [imgFile],
title: baseTitle,
text: fullMessage,
};
} else {
shareData = {
title: baseTitle,
text: fullMessage,
url: window.location.href,
};
}
setIsGeneratingImage(false);
try {
if (!navigator.canShare(shareData)) {
toast.error(
'Gagal membagikan checklist, coba dengan perangkat yang berbeda'
);
return;
}
await navigator.share(shareData);
toast.success('Checklist berhasil dibagikan');
} catch (error) {
toast.error('Gagal membagikan checklist');
}
};
const shareToWhatsAppHandler = async () => {
const isMobile = isMobileDevice();
setIsGeneratingImage(true);
const statusMsg = getStatusMessage();
const category = header?.category || '';
const message = encodeURIComponent(
`Daily Checklist\n\nTanggal: ${formatDate(header?.date || '')}\nKandang: ${header?.kandang_name}\nKategori: ${CATEGORY_LABELS[category] || category}\nProgress: ${header?.progress_percent}%\nStatus: ${header?.status}${statusMsg ? ` - ${statusMsg}` : ''}\n\nLihat detail lengkap: ${window.location.href}`
);
setIsGeneratingImage(false);
const whatsappUrl = isMobile
? `https://wa.me/?text=${message}`
: `https://web.whatsapp.com/send?text=${message}`;
window.open(whatsappUrl, '_blank');
};
if (loading) { if (loading) {
return ( return (
<div className='min-h-screen'> <div className='min-h-screen'>
@@ -687,8 +573,8 @@ export function DetailDailyChecklistContent() {
return ( return (
<div className='min-h-screen'> <div className='min-h-screen'>
<div className='p-6'> <div className='p-6'>
{/* Action Buttons */} {/* Page Title with Back Button */}
<div className='mb-6 flex items-start sm:items-center justify-between gap-4 flex-wrap'> <div className='mb-6 flex items-center gap-4'>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
@@ -698,68 +584,37 @@ export function DetailDailyChecklistContent() {
<ArrowLeft className='w-4 h-4 mr-1' /> <ArrowLeft className='w-4 h-4 mr-1' />
Kembali Kembali
</Button> </Button>
<div className='flex-1'>
<div className='flex items-center gap-2 flex-wrap'> <h1 className='text-2xl font-semibold text-gray-900'>
{header.status === 'SUBMITTED' && ( Detail Daily Checklist
<RequirePermission permissions='lti.daily_checklist.create'> </h1>
<div className='flex gap-2 flex-wrap'> <p className='text-sm text-gray-600 mt-1'>
<Button Lihat detail checklist harian
onClick={handleApprove} </p>
disabled={actionLoading}
className='bg-green-600 hover:bg-green-700 text-white'
>
<CheckCircle className='w-4 h-4 mr-2' />
Approve
</Button>
<Button
onClick={handleReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-2' />
Reject
</Button>
</div>
</RequirePermission>
)}
<Button
variant='outline'
size='sm'
onClick={shareHandler}
disabled={isGeneratingImage}
className='border-gray-200'
>
<Share2 className='w-4 h-4 mr-1' />
{!isGeneratingImage && 'Bagikan'}
{isGeneratingImage && 'Memuat...'}
</Button>
<Button
variant='outline'
size='sm'
onClick={shareToWhatsAppHandler}
disabled={isGeneratingImage}
className='border-gray-200'
>
<Icon icon='mdi:whatsapp' className='w-4 h-4 mr-1' />
{!isGeneratingImage && 'Bagikan via WhatsApp'}
{isGeneratingImage && 'Memuat...'}
</Button>
</div> </div>
</div> {header.status === 'SUBMITTED' && (
<RequirePermission permissions='lti.daily_checklist.create'>
{/* Page Title */} <div className='flex gap-2'>
<div className='mb-6'> <Button
<h1 className='text-2xl font-semibold text-gray-900'> onClick={handleApprove}
Detail Daily Checklist disabled={actionLoading}
</h1> className='bg-green-600 hover:bg-green-700 text-white'
<p className='text-sm text-gray-600 mt-1'> >
Lihat detail checklist harian <CheckCircle className='w-4 h-4 mr-2' />
</p> Approve
</Button>
<Button
onClick={handleReject}
disabled={actionLoading}
variant='destructive'
className='bg-red-600 hover:bg-red-700 text-white'
>
<XCircle className='w-4 h-4 mr-2' />
Reject
</Button>
</div>
</RequirePermission>
)}
</div> </div>
{/* Header Info Card */} {/* Header Info Card */}
@@ -784,18 +639,6 @@ export function DetailDailyChecklistContent() {
{CATEGORY_LABELS[header.category] || header.category} {CATEGORY_LABELS[header.category] || header.category}
</p> </p>
</div> </div>
{header.category === 'empty_kandang' && (
<div>
<Label className='text-xs text-gray-500'>
Tanggal Selesai Kandang Kosong
</Label>
<p className='text-sm font-medium text-gray-900 mt-1'>
{header.empty_kandang_end_date
? formatDate(header.empty_kandang_end_date)
: '-'}
</p>
</div>
)}
<div> <div>
<Label className='text-xs text-gray-500'>Status</Label> <Label className='text-xs text-gray-500'>Status</Label>
<div className='mt-1'>{getStatusBadge(header.status)}</div> <div className='mt-1'>{getStatusBadge(header.status)}</div>
@@ -96,10 +96,7 @@ export function MasterEmployeeContent() {
options: kandangOptions, options: kandangOptions,
loadMore: loadMoreKandang, loadMore: loadMoreKandang,
isLoadingMore: isLoadingMoreKandang, isLoadingMore: isLoadingMoreKandang,
} = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name', 'search', { } = useSelect(DailyChecklistKandangApi.basePath, 'id', 'name');
order_by: 'asc',
sort_by: 'name',
});
const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleKandangScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
@@ -220,9 +217,7 @@ export function MasterEmployeeContent() {
'Error creating employee:', 'Error creating employee:',
createEmployeeResponse.message createEmployeeResponse.message
); );
toast.error( toast.error('Gagal menambahkan ABK');
'Gagal menambahkan ABK: ' + createEmployeeResponse.message
);
return; return;
} }
@@ -243,9 +238,7 @@ export function MasterEmployeeContent() {
'Error updating employee:', 'Error updating employee:',
updateEmployeeResponse.message updateEmployeeResponse.message
); );
toast.error( toast.error('Gagal menambahkan ABK');
'Gagal memperbarui ABK: ' + updateEmployeeResponse.message
);
return; return;
} }
@@ -49,8 +49,9 @@ import { cn } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { useSelect } from '@/components/input/SelectInput'; import { useSelect } from '@/components/input/SelectInput';
import { LocationApi } from '@/services/api/master-data'; import { KandangApi, LocationApi } from '@/services/api/master-data';
import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import { BaseDailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { UserApi } from '@/services/api/user'; import { UserApi } from '@/services/api/user';
export function MasterKandangContent() { export function MasterKandangContent() {
@@ -107,6 +108,12 @@ export function MasterKandangContent() {
} }
); );
const {
options: kandangOptions,
isLoadingMore: isLoadingKandangOptionsMore,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name');
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [kandangToDelete, setKandangToDelete] = useState<number | null>(null); const [kandangToDelete, setKandangToDelete] = useState<number | null>(null);
@@ -368,9 +375,7 @@ export function MasterKandangContent() {
name='search' name='search'
placeholder='Cari kandang...' placeholder='Cari kandang...'
value={tableFilterState.search} value={tableFilterState.search}
onChange={(e) => onChange={(e) => updateFilter('search', e.target.value)}
updateFilter('search', e.target.value, true)
}
className={{ className={{
wrapper: 'w-full sm:w-[280px] border-gray-200', wrapper: 'w-full sm:w-[280px] border-gray-200',
inputWrapper: 'px-3 py-2 h-fit rounded-md', inputWrapper: 'px-3 py-2 h-fit rounded-md',
@@ -385,11 +390,7 @@ export function MasterKandangContent() {
<Select <Select
value={tableFilterState.location_id} value={tableFilterState.location_id}
onValueChange={(value) => onValueChange={(value) =>
updateFilter( updateFilter('location_id', value === 'all' ? '' : value)
'location_id',
value === 'all' ? '' : value,
true
)
} }
> >
<SelectTrigger className='w-[180px] border-gray-200'> <SelectTrigger className='w-[180px] border-gray-200'>

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