Compare commits

..

4 Commits

Author SHA1 Message Date
rstubryan 3b9bd3c5bd Revert "refactor(FE): Prevent adding recordings for kandangs in transition"
This reverts commit 9dc30c1f58.
2026-03-09 03:50:33 +07:00
rstubryan 9dc30c1f58 refactor(FE): Prevent adding recordings for kandangs in transition 2026-03-09 03:35:03 +07:00
rstubryan 671fd72141 refactor(FE): Make stock fields optional during transition to laying 2026-03-09 03:32:44 +07:00
rstubryan d236138aa7 refactor(FE): Update recording editability logic and extend
BaseRecording type
2026-03-09 03:18:41 +07:00
163 changed files with 4517 additions and 12448 deletions
-3
View File
@@ -45,6 +45,3 @@ next-env.d.ts
# claude
.claude
# rtk
rtk.exe
+3 -50
View File
@@ -15,7 +15,7 @@ default:
# ==========================================================
.build_template: &build_template
stage: build
image: public.ecr.aws/docker/library/node:20-alpine
image: node:20-alpine
cache:
key: npm-cache
paths:
@@ -30,10 +30,6 @@ default:
- echo "NEXT_PUBLIC_LTI_URL=$NEXT_PUBLIC_LTI_URL"
- echo "NEXT_PUBLIC_SSO_LOGIN_URL=$NEXT_PUBLIC_SSO_LOGIN_URL"
- echo "NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL"
- echo "NEXT_PUBLIC_APP_ENV=$NEXT_PUBLIC_APP_ENV"
- echo "NEXT_PUBLIC_HELPDESK_URL=$NEXT_PUBLIC_HELPDESK_URL"
- echo "NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL=$NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL"
- echo "NEXT_PUBLIC_S3_PUBLIC_BASE_URL=$NEXT_PUBLIC_S3_PUBLIC_BASE_URL"
- echo "Building Next.js static export..."
- npx next build
- |
@@ -45,11 +41,7 @@ default:
"built_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"NEXT_PUBLIC_LTI_URL": "$NEXT_PUBLIC_LTI_URL",
"NEXT_PUBLIC_SSO_LOGIN_URL": "$NEXT_PUBLIC_SSO_LOGIN_URL",
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_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"
"NEXT_PUBLIC_API_BASE_URL": "$NEXT_PUBLIC_API_BASE_URL"
}
EOF
artifacts:
@@ -64,7 +56,7 @@ default:
.deploy_template: &deploy_template
stage: deploy
image:
name: public.ecr.aws/aws-cli/aws-cli:latest
name: amazon/aws-cli:latest
entrypoint: ['/bin/sh', '-c']
script:
- set -e
@@ -150,10 +142,6 @@ build:dev:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://dev-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://dev-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'development'
NEXT_PUBLIC_HELPDESK_URL: 'https://dev-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dev-dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com'
deploy:dev:
<<: *deploy_template
@@ -182,9 +170,6 @@ build:staging:
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://stg-auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://stg-api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'staging'
NEXT_PUBLIC_HELPDESK_URL: 'https://stg-helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://stg-dashboard-ho.mbugroup.id/'
deploy:staging:
<<: *deploy_template
@@ -198,35 +183,3 @@ deploy:staging:
environment:
name: staging
url: https://stg-lti-erp.mbugroup.id
# ==========================================================
# ====== (Branch production) ======
# ==========================================================
build:production:
<<: *build_template
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
environment:
name: staging
variables:
NEXT_PUBLIC_LTI_URL: 'https://lti-erp.mbugroup.id'
NEXT_PUBLIC_SSO_LOGIN_URL: 'https://auth-erp.mbugroup.id'
NEXT_PUBLIC_API_BASE_URL: 'https://api-lti.mbugroup.id/api'
NEXT_PUBLIC_CLIENT_ID: 'Lumbung-Telur-Indonesia'
NEXT_PUBLIC_APP_ENV: 'production'
NEXT_PUBLIC_HELPDESK_URL: 'https://helpdesk.mbugroup.id/'
NEXT_PUBLIC_DASHBOARD_ACCOUNTING_URL: 'https://dashboard-ho.mbugroup.id/'
NEXT_PUBLIC_S3_PUBLIC_BASE_URL: 'https://mbu-lti-storage.s3.ap-southeast-3.amazonaws.com/'
deploy:production:
<<: *deploy_template
needs: ['build:production']
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
variables:
S3_BUCKET: 'production-lti-erp.mbugroup.id'
AWS_REGION: 'ap-southeast-3'
CLOUDFRONT_DISTRIBUTION_ID: 'E1SSLXKYYITASJ'
environment:
name: staging
url: https://lti-erp.mbugroup.id
+1 -2
View File
@@ -1,4 +1,3 @@
npm run format
npm run lint
npm run typecheck
git add .
npx tsc --noEmit
-262
View File
@@ -1,262 +0,0 @@
# LTI Web Client
Next.js 15 (App Router) + React 19 + TypeScript front-end for the LTI ERP system.
## Tech stack
- **Framework:** Next.js 15.5 (App Router, Turbopack)
- **UI:** React 19, Tailwind CSS v4, Radix UI, daisyUI, lucide-react
- **State:** zustand
- **Forms:** Formik + Yup, react-hook-form
- **Data fetching:** axios + SWR (custom `httpClient` / `httpClientFetcher` in `src/services/http`)
- **Tables:** @tanstack/react-table
- **Reporting:** @react-pdf/renderer, jspdf, exceljs, xlsx, recharts
## Scripts
- `npm run dev` — lint + dev server (Turbopack)
- `npm run build` — production build
- `npm run lint` — ESLint
- `npm run typecheck``next typegen && tsc --noEmit`
- `npm run format` — Prettier
- `npm run pre-commit` — format + lint + typecheck + build (Husky pre-commit hook)
## Project structure
```
src/
app/ # Next.js App Router routes (one folder per feature)
components/
pages/{feature}/ # Page-specific components (mirrors src/app)
helper/ # Cross-cutting helpers (e.g. SuspenseHelper)
ui/ # Shared UI primitives
services/
api/ # API service classes (extend BaseApiService)
http/ # httpClient / httpClientFetcher
hooks/ # Service-level hooks
stores/ # zustand stores grouped by domain
types/api/ # Request/response types per feature
lib/ # Shared helpers (api-helper, formik-helper, utils, validation, …)
config/, styles/
```
## Feature development standard
**Always follow this order when adding a new feature.** This is a team convention — deviating creates churn in code review.
1. **Types** — Define payload and response types in `src/types/api/{feature}` (or `{feature}.d.ts` for small features).
2. **API service** — Add `src/services/api/{feature}.ts` exporting a class that extends `BaseApiService<T, CreatePayload, UpdatePayload>` from [src/services/api/base.ts](src/services/api/base.ts). Use a subfolder (e.g. `src/services/api/daily-checklist/`) when the feature has multiple resource classes.
3. **Page** — Create the route under `src/app/{feature}` and a matching `src/components/pages/{feature}` folder for its components.
4. **Component slicing** — Break the page UI into components inside `src/components/pages/{feature}`.
5. **Wire up the API** — Consume the service class from step 2 inside the page/components (often via SWR).
6. **Detail layout** — When a route reads URL params via `useSearchParams` (e.g. `/feature/detail?id=123`), add `src/app/{feature}/detail/layout.tsx` that wraps `children` in `<SuspenseHelper>` from `@/components/helper/SuspenseHelper`.
7. **Shared state** — Use zustand stores in `src/stores/{domain}` when state must cross component boundaries.
8. **Helpers** — Reuse from [src/lib](src/lib) first (`api-helper.ts`, `formik-helper.ts`, `utils/`, `validation/`, etc.). Add new helpers there.
### Reference implementations
`closing`, `finance`, `expense`, `production`, `inventory`, `marketing`, `master-data`, `purchase`, `report`, `daily-checklist`, `dashboard` — all live in both `src/app/{feature}` and `src/components/pages/{feature}` and follow the standard above.
## Conventions
- Path alias `@/` maps to `src/`.
- Detail pages that read `useSearchParams` MUST be wrapped in `<SuspenseHelper>` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern).
- API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components.
- Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`.
## Table filter persistence pattern
Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes.
**Three core principles (apply to all table components):**
1. **Set formik initialValues from tableFilterState** (not hardcoded defaults)
- Ensures the filter modal displays currently active filters when opened
- Initialize directly from persisted state: `location: tableFilterState.locationFilter`
2. **Pass `true` as last parameter to updateFilter calls**
- `updateFilter('fieldName', value, true)` immediately persists to localStorage
- Resets pagination to page 1 when filters change (via SWR revalidation)
- Apply to: search handlers, filter form submissions, reset handlers
3. **Create custom formikResetHandler function**
- Clear each filter with `updateFilter(fieldName, defaultValue, true)`
- Call `formik.resetForm({ values: { ...defaults } })`
- Close the modal at the end
- Attach to both button `onClick` and form `onReset` handler
**Optimization: Avoid useCallback for simple handlers**
- `useCallback` adds overhead and is only useful for complex logic or memoized child components
- Simple pass-through handlers don't need it:
```tsx
// ✅ Good: Simple handler without useCallback
const handleFilterChange = (val) => setFieldValue('location', val);
// ❌ Avoid: Unnecessary useCallback overhead
const handleFilterChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
```
**Best practice: Store OptionType objects directly, not IDs**
For select inputs, store the complete `OptionType` object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
```tsx
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined
},
paramMap: {
page: 'page',
pageSize: 'limit',
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik<KandangFilterType>({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
...
});
// Handlers store the complete OptionType, not just the ID
const handleFilterLocationChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
```
**Apply this pattern to:**
- Any data table component across any module that needs persistent filters
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
- Whenever users' filter/search/pagination choices should survive page refreshes
**Reference implementations:**
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
## Server-side sorting pattern
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
**Four-part wiring:**
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
```ts
initial: { sort_by: '', order_by: '' }
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
```
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
```ts
useEffect(() => {
if (sorting.length > 0) {
updateFilter('sort_by', sorting[0].id, true);
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '');
updateFilter('order_by', '');
}
}, [sorting]);
```
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
```tsx
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
```
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
## Server-side file export pattern
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
### Service method (in `src/services/api/{feature}.ts`)
```ts
async exportToExcel(initialQueryString: string) {
const params = new URLSearchParams(initialQueryString);
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
params.set('page', '1');
params.set('limit', '99999999999');
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
method: 'GET',
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([res]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
document.body.appendChild(link);
link.click();
link.remove();
}
```
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
- Add one method per format; keep them side-by-side in the same service class.
### Component handler (in the page/tab component)
```ts
const handleExportExcel = useCallback(async () => {
setIsExcelExportLoading(true);
try {
const params = new URLSearchParams();
if (filterParams.foo) params.set('foo', filterParams.foo);
// ... map all active filter params ...
await FeatureApi.exportToExcel(params.toString());
toast.success('Excel berhasil dibuat dan diunduh.');
} catch {
toast.error('Gagal membuat Excel. Silakan coba lagi.');
} finally {
setIsExcelExportLoading(false);
}
}, [filterParams, searchValue]);
```
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
+2 -2
View File
@@ -1,4 +1,4 @@
FROM public.ecr.aws/docker/library/node:20-alpine
FROM node:20-alpine
RUN apk add --no-cache git bash build-base curl
@@ -22,4 +22,4 @@ RUN mkdir -p .next/server/app/_next && \
EXPOSE 3000
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
CMD ["npx", "serve", ".next/server/app", "-l", "3000"]
+1 -3
View File
@@ -7,10 +7,8 @@
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"typecheck": "next typegen && tsc --noEmit",
"prepare": "husky",
"format": "prettier --write .",
"pre-commit": "npm run format && npm run lint && npm run typecheck && npm run build"
"format": "prettier --write ."
},
"dependencies": {
"@react-pdf/renderer": "^4.3.1",
@@ -1,11 +0,0 @@
import { MasterKandangContent } from '@/figma-make/components/pages/master-data/kandang/MasterKandangContent';
const MasterKandangPage = () => {
return (
<section className='w-full'>
<MasterKandangContent />
</section>
);
};
export default MasterKandangPage;
+2 -2
View File
@@ -15,8 +15,8 @@ const ExpenseDetailPage = () => {
const expenseId = searchParams.get('expenseId');
const { data: expense, isLoading: isLoadingExpense } = useSWR(
['expense-detail', expenseId],
([_, id]) => ExpenseApi.getSingle(Number(id))
expenseId,
(id: number) => ExpenseApi.getSingle(id)
);
if (!expenseId) {
@@ -1,11 +0,0 @@
import { SystemConfigContent } from '@/figma-make/components/pages/master-data/system-config/SystemConfigContent';
const SystemConfigPage = () => {
return (
<section className='w-full'>
<SystemConfigContent />
</section>
);
};
export default SystemConfigPage;
@@ -11,13 +11,10 @@ const RecordingEdit = () => {
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingDetailKey,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
recordingId,
(id: string) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
+2 -5
View File
@@ -11,13 +11,10 @@ const RecordingDetail = () => {
const searchParams = useSearchParams();
const recordingId = searchParams.get('recordingId');
const recordingDetailKey = recordingId
? ['recording-detail', recordingId]
: null;
const { data: recording, isLoading: isLoadingRecording } = useSWR(
recordingDetailKey,
([, id]: [string, string]) => RecordingApi.getSingle(parseInt(id))
recordingId,
(id: string) => RecordingApi.getSingle(parseInt(id))
);
if (!recordingId) {
+3 -3
View File
@@ -51,7 +51,7 @@ const Button = ({
return (
<>
{(!href || (href && disabled)) && (
{!href && (
<button
{...props}
type={type}
@@ -68,9 +68,9 @@ const Button = ({
</button>
)}
{href && !disabled && (
{href && (
<Link
href={href}
href={disabled ? '#' : href}
target={target}
rel={rel}
aria-disabled={disabled}
+1 -1
View File
@@ -226,7 +226,7 @@ const Pagination = ({
const PageInfo = () => (
<span className='text-nowrap text-sm font-medium text-base-content/50'>
Total Item: {totalItems} | Page {currentPage} of {totalPages}
Page {currentPage} of {totalPages}
</span>
);
+1 -3
View File
@@ -35,9 +35,7 @@ const NumberInput = ({
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = parseFloat(
numberFormatValues.value
) as unknown as string;
newChangeEvent.target.value = numberFormatValues.value;
onChange?.(newChangeEvent);
}
+17 -25
View File
@@ -24,8 +24,8 @@ import {
} from '@/types/api/api-general';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
export interface OptionType<T = string | number> {
value: T;
export interface OptionType {
value: string | number;
label: string;
className?: string;
labelClassName?: string;
@@ -523,7 +523,7 @@ const useSelect = <T,>(
const qs = new URLSearchParams({
...(params ?? {}),
[searchKey ? searchKey : 'search']: inputValue ?? '',
[searchKey]: inputValue ?? '',
[pageKey]: String(pageIndex + 1),
[limitKey]: String(limit),
}).toString();
@@ -566,31 +566,23 @@ const useSelect = <T,>(
setSize(size + 1);
};
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
const { formattedSuccessRawData, formattedErrorRawData } = useMemo(() => {
let successData: SuccessApiResponse<T[]> | undefined = undefined;
let errorData: ErrorApiResponse | undefined = undefined;
if (isResponseSuccess(pages?.[latestPagesIndex])) {
successData = {
...pages![latestPagesIndex],
data:
pages?.flatMap((page) =>
isResponseSuccess(page) ? page.data : []
) ?? [],
};
}
if (isResponseError(pages?.[latestPagesIndex])) {
errorData = pages![latestPagesIndex];
}
return {
formattedSuccessRawData: successData,
formattedErrorRawData: errorData,
if (isResponseSuccess(pages?.[latestPagesIndex])) {
formattedSuccessRawData = {
...pages?.[latestPagesIndex],
data:
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
[],
};
}, [pages, latestPagesIndex]);
}
if (isResponseError(pages?.[latestPagesIndex])) {
formattedErrorRawData = pages?.[latestPagesIndex];
}
return {
inputValue,
@@ -112,11 +112,12 @@ const ClosingDetail: React.FC<ClosingDetailProps> = ({
kandangData={kandangData}
/>
<ClosingKandangList
initialValue={initialValue}
projectData={projectData}
selectedKandangId={kandangData?.id}
/>
{!kandangData && (
<ClosingKandangList
initialValue={initialValue}
projectData={projectData}
/>
)}
<Tabs
activeTabId={activeTabId}
@@ -5,11 +5,9 @@ import { ProjectFlock } from '@/types/api/production/project-flock';
const ClosingKandangList = ({
initialValue,
projectData,
selectedKandangId,
}: {
initialValue?: ClosingGeneralInformation;
projectData?: ProjectFlock;
selectedKandangId?: number;
}) => {
return (
<div className='w-full py-3 @container relative before:absolute before:top-0 before:left-0 before:right-0 before:-mx-4 before:border-t before:border-base-content/10'>
@@ -24,9 +22,6 @@ const ClosingKandangList = ({
variant='outline'
className='px-3 py-2.5 w-fit text-sm rounded-lg shadow-sm'
href={`/closing/detail/?closingId=${initialValue?.flock_id}&kandangId=${kandang.project_flock_kandang_id}`}
disabled={
selectedKandangId === kandang.project_flock_kandang_id
}
>
{kandang.name}
</Button>
@@ -276,7 +276,7 @@ const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => {
{
id: 'kandang',
accessorKey: 'kandang',
header: 'Kandang Atribusi',
header: 'Kandang',
cell: (props) => {
const kandang = props.getValue() as Kandang;
return kandang?.name || '-';
@@ -127,11 +127,11 @@ const ClosingOutgoingSapronaksTable = ({
},
{
accessorKey: 'source_warehouse',
header: 'Gudang Asal (Fisik)',
header: 'Gudang Asal',
},
{
accessorKey: 'destination_warehouse',
header: 'Gudang Tujuan (Fisik)',
header: 'Gudang Tujuan',
},
{
accessorKey: 'quantity',
@@ -9,11 +9,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import useSWR from 'swr';
import { DashboardApi } from '@/services/api/dashboard';
import { useFormik } from 'formik';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
} from '@/services/api/production';
import { LocationApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import { generateDashboardPDF } from '@/components/pages/dashboard/export/DashboardPDF';
import {
DashboardFilterType,
@@ -25,7 +22,10 @@ import DashboardExportCharts, {
DashboardExportChartsRef,
} from '@/components/pages/dashboard/export/DashboardExportCharts';
import { RadioGroup, RadioGroupItem } from '@/components/input/RadioInput';
import { DashboardMeta } from '@/types/api/dashboard/dashboard';
import {
DashboardFilter,
DashboardMeta,
} from '@/types/api/dashboard/dashboard';
import DashboardStats from '@/components/pages/dashboard/chart/DashboardStats';
import { isResponseSuccess } from '@/lib/api-helper';
import AlertErrorList from '@/components/helper/form/FormErrors';
@@ -42,8 +42,6 @@ import { cn } from '@/lib/helper';
import DashboardExportStats, {
DashboardExportStatsRef,
} from '@/components/pages/dashboard/export/DashboardExportStats';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
// Helper function to normalize values to array
const normalizeToArray = (
@@ -70,6 +68,7 @@ const DashboardProduction = () => {
const [analysisMode, setAnalysisMode] = useState<'OVERVIEW' | 'COMPARISON'>(
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') || 'OVERVIEW'
);
const [endpointUrl, setEndpointUrl] = useState('/dashboards');
const [selectedLocationIds, setSelectedLocationIds] = useState<number[]>(
normalizeToArray(filterValues.location)
);
@@ -81,29 +80,9 @@ const DashboardProduction = () => {
const {
data: dashboardProductionResponse,
isLoading: isLoadingDashboardProductionData,
} = useSWR(
[
'dashboard-production',
filterValues.startDate ?? '',
filterValues.endDate ?? '',
filterValues.analysisMode ?? 'OVERVIEW',
normalizeToArray(filterValues.location).toString(),
normalizeToArray(filterValues.flock).toString(),
normalizeToArray(filterValues.kandang).toString(),
filterValues.comparisonType ?? '',
],
() =>
DashboardApi.getDashboardProductionFetcher({
start_date: filterValues.startDate || '',
end_date: filterValues.endDate || '',
analysis_mode:
(filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON') ||
'OVERVIEW',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType || '',
})
mutate: refreshDashboardProductionData,
} = useSWR(endpointUrl, () =>
DashboardApi.getDashboardProductionFetcher(endpointUrl)
);
const dashboardProductionData = isResponseSuccess(dashboardProductionResponse)
@@ -116,23 +95,23 @@ const DashboardProduction = () => {
options: flockOptions,
isLoadingOptions: isLoadingFlockOptions,
loadMore: loadMoreFlock,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
}
);
} = useSelect(ProjectFlockApi.basePath, 'id', 'flock_name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const {
setInputValue: setInputValueLocation,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect(KandangApi.basePath, 'id', 'name', '', {
location_id: selectedLocationIds ? selectedLocationIds.toString() : '',
});
const comparisonTypeOptions = [
{ value: 'FARM', label: 'Farm' },
{ value: 'FLOCK', label: 'Flock' },
@@ -156,43 +135,68 @@ const DashboardProduction = () => {
enableReinitialize: true,
validationSchema: getDashboardFilterSchema(analysisMode),
onSubmit: (values) => {
// Save filter values to store
setFilterValues(values);
filterModal.closeModal();
handleApplyFilter({
start_date: values.startDate || '',
end_date: values.endDate || '',
analysis_mode: values.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(values.location),
flock_ids: normalizeToArray(values.flock),
kandang_ids: normalizeToArray(values.kandang),
comparison_type: values.comparisonType,
});
},
});
const { resetForm } = formik;
const selectedLocationValues = normalizeToArray(formik.values.location);
const selectedFlockValues = normalizeToArray(formik.values.flock);
const {
setInputValue: setInputValueKandang,
options: kandangOptions,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandang,
} = useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'kandang_id',
'kandang.name',
'search',
{
location_id:
selectedLocationValues.length > 0
? selectedLocationValues.toString()
: '',
project_flock_id:
selectedFlockValues.length > 0 ? selectedFlockValues.toString() : '',
}
);
const handleResetFilter = useCallback(() => {
resetForm();
resetFilterValues(); // Clear stored filter values
setAnalysisMode('OVERVIEW');
setEndpointUrl('/dashboards');
setSelectedLocationIds([]);
filterModal.closeModal();
}, [filterModal, resetForm, resetFilterValues]);
}, [resetForm, resetFilterValues]);
const handleApplyFilter = useCallback(
(values: DashboardFilter) => {
// Build query params object, only include non-empty values
const params: Record<string, string> = {};
if (values.start_date) params.start_date = values.start_date;
if (values.end_date) params.end_date = values.end_date;
if (values.analysis_mode) params.analysis_mode = values.analysis_mode;
if (values.location_ids.length > 0)
params.location_ids = values.location_ids.toString();
if (values.flock_ids.length > 0)
params.flock_ids = values.flock_ids.toString();
if (values.kandang_ids.length > 0)
params.kandang_ids = values.kandang_ids.toString();
if (values.comparison_type)
params.comparison_type = values.comparison_type;
setEndpointUrl(`/dashboards?${new URLSearchParams(params).toString()}`);
filterModal.closeModal();
refreshDashboardProductionData();
},
[filterModal, refreshDashboardProductionData]
);
// ===== Load filter from store on mount =====
useEffect(() => {
if (!filterValues) return;
handleApplyFilter({
start_date: filterValues.startDate,
end_date: filterValues.endDate,
analysis_mode: filterValues.analysisMode as 'OVERVIEW' | 'COMPARISON',
location_ids: normalizeToArray(filterValues.location),
flock_ids: normalizeToArray(filterValues.flock),
kandang_ids: normalizeToArray(filterValues.kandang),
comparison_type: filterValues.comparisonType,
});
}, [filterValues, handleApplyFilter]);
// ===== Formik Error List =====
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
@@ -264,6 +268,14 @@ const DashboardProduction = () => {
};
}, [clearNavbarActions]);
if (isLoadingDashboardProductionData) {
return (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
);
}
return (
<>
<section className='w-full p-3 space-y-3'>
@@ -315,15 +327,9 @@ const DashboardProduction = () => {
</div>
{/* Dashboard Stats */}
<div>
{isLoadingDashboardProductionData ? (
<div className='w-full min-h-screen flex items-center justify-center'>
<span className='loading loading-spinner loading-xl'></span>
</div>
) : (
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
)}
<DashboardStats
data={dashboardProductionData?.statistics_data ?? []}
/>
</div>
{/* Use DashboardLineChart component or skeleton */}
@@ -531,7 +537,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -568,7 +573,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -600,7 +604,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
@@ -640,7 +643,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -667,7 +669,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -706,7 +707,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
) : (
<SelectInputRadio
@@ -733,7 +733,6 @@ const DashboardProduction = () => {
className={{
select: 'rounded-lg text-sm border-base-content/10',
}}
isClearable={true}
/>
)}
</>
@@ -1,7 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
@@ -16,7 +15,6 @@ interface ExpenseDetailProps {
}
const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
const router = useRouter();
const [activeTab, setActiveTab] = useState<string>('request');
const expenseDetailTabs = useMemo(() => {
@@ -48,8 +46,8 @@ const ExpenseDetail: React.FC<ExpenseDetailProps> = ({ initialValues }) => {
<section className='w-full max-w-full pb-16'>
<header className='flex flex-col gap-4'>
<Button
href='/expense'
variant='link'
onClick={router.back}
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
@@ -1,8 +1,5 @@
'use client';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Icon } from '@iconify/react';
@@ -19,7 +16,6 @@ import {
} from '@/components/pages/expense/form/ExpenseRequestForm.schema';
import { ExpenseApi } from '@/services/api/expense';
import { isResponseSuccess } from '@/lib/api-helper';
import { buildExpenseActionHref } from '@/lib/expense-list-navigation';
import { ACCEPTED_FILE_TYPE, S3_PUBLIC_BASE_URL } from '@/config/constant';
interface ExpenseRealizationContentProps {
@@ -29,8 +25,6 @@ interface ExpenseRealizationContentProps {
const ExpenseRealizationContent = ({
initialValues,
}: ExpenseRealizationContentProps) => {
const searchParams = useSearchParams();
const formik = useFormik<UploadRequestDocumentsFormValues>({
initialValues: {
documents: [],
@@ -80,11 +74,7 @@ const ExpenseRealizationContent = ({
<Button
type='button'
color='warning'
href={buildExpenseActionHref(
'/expense/realization/edit/',
initialValues?.id as number,
searchParams
)}
href={`/expense/realization/edit/?expenseId=${initialValues?.id}`}
className='px-4 grow sm:grow-0'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -1,8 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSWRConfig } from 'swr';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -20,7 +19,6 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import ExpensePDFPreviewButton from '@/components/pages/expense//pdf/ExpensePDFButton';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense';
import { formatCurrency, formatDate } from '@/lib/helper';
@@ -28,15 +26,11 @@ import {
UploadRequestDocumentsFormSchema,
UploadRequestDocumentsFormValues,
} 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 { isResponseSuccess } from '@/lib/api-helper';
import { EXPENSE_REQUEST_APPROVAL_LINE } from '@/config/approval-line';
import { BaseApiResponse } from '@/types/api/api-general';
import {
buildExpenseActionHref,
getExpenseListReturnTo,
} from '@/lib/expense-list-navigation';
interface ExpenseRequestContentProps {
initialValues?: Expense;
@@ -46,13 +40,6 @@ const ExpenseRequestContent = ({
initialValues,
}: ExpenseRequestContentProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const { mutate } = useSWRConfig();
const refreshExpense = () => {
mutate((key) => Array.isArray(key) && key[0] === 'expense-detail');
};
const { approvals: approvalHistory, isLoading: isLoadingApprovalHistory } =
useApprovalSteps({
@@ -102,24 +89,17 @@ const ExpenseRequestContent = ({
!isLatestApprovalRejected &&
initialValues?.latest_approval.step_number === 4;
const isExpensePaidOff = initialValues?.is_paid;
const showPaidOffButton =
!isExpensePaidOff && (initialValues?.latest_approval.step_number ?? 0) >= 4;
// Modal hooks
const deleteModal = useModal();
const completeModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const paidOffModal = useModal();
// Modal loading state
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isCompleteLoading, setIsCompleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const formik = useFormik<UploadRequestDocumentsFormValues>({
@@ -160,31 +140,7 @@ const ExpenseRequestContent = ({
rejectModal.openModal();
};
const paidOffClickHandler = () => {
paidOffModal.openModal();
};
// Modal confirm click handler
const confirmationModalPaidOffClickHandler = async () => {
setIsPaidOffLoading(true);
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
initialValues?.id as number
);
if (isResponseSuccess(paidOffResponse)) {
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
refreshExpense();
} else {
toast.error(
'Gagal menandai biaya operasional sebagai lunas!: ' +
paidOffResponse?.message
);
}
paidOffModal.closeModal();
setIsPaidOffLoading(false);
};
const confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -192,7 +148,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(deleteResponse)) {
toast.success('Berhasil menghapus data biaya operasional!');
router.push(returnTo);
router.push('/expense');
} else {
toast.error('Gagal menghapus data biaya operasional!');
}
@@ -208,7 +164,7 @@ const ExpenseRequestContent = ({
if (isResponseSuccess(completeRes)) {
toast.success(completeRes.message);
router.push(returnTo);
router.push('/expense');
} else {
toast.error(completeRes?.message as string);
}
@@ -248,7 +204,7 @@ const ExpenseRequestContent = ({
toast.success(approveResponse?.message);
setApprovalNotes('');
router.push(returnTo);
router.push('/expense');
} else {
approveModal.closeModal();
@@ -283,7 +239,7 @@ const ExpenseRequestContent = ({
toast.success(rejectResponse.message);
setApprovalNotes('');
router.push(returnTo);
router.push('/expense');
} else {
rejectModal.closeModal();
@@ -323,6 +279,8 @@ const ExpenseRequestContent = ({
)}
<div className='w-full mt-4 flex flex-col gap-4'>
{/* TODO: apply RBAC */}
<div className='w-full mx-auto flex flex-col sm:flex-row justify-end gap-2'>
{isCurrentApprovalOnHeadArea && (
<RequirePermission permissions='lti.expense.approve.head_area'>
@@ -409,11 +367,7 @@ const ExpenseRequestContent = ({
<Button
variant='outline'
color='info'
href={buildExpenseActionHref(
'/expense/realization/',
initialValues?.id as number,
searchParams
)}
href={`/expense/realization/?expenseId=${initialValues?.id}`}
className='w-full sm:w-fit'
>
<Icon
@@ -426,35 +380,13 @@ const ExpenseRequestContent = ({
</RequirePermission>
)}
{showPaidOffButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
variant='outline'
color='success'
onClick={paidOffClickHandler}
className='w-full sm:w-fit'
>
<Icon
icon='material-symbols:check-circle-outline'
width={24}
height={24}
/>
Tandai Lunas
</Button>
</RequirePermission>
)}
<div className='w-full sm:w-fit sm:ml-2 flex flex-row gap-2 items-center'>
{showEditButton && (
<RequirePermission permissions='lti.expense.update'>
<Button
type='button'
color='warning'
href={buildExpenseActionHref(
'/expense/detail/edit/',
initialValues?.id as number,
searchParams
)}
href={`/expense/detail/edit/?expenseId=${initialValues?.id}`}
className='px-4 grow sm:grow-0'
>
<Icon icon='mdi:pencil-outline' width={24} height={24} />
@@ -589,19 +521,6 @@ const ExpenseRequestContent = ({
/>
</td>
</tr>
<tr>
<th>Status Lunas</th>
<th>:</th>
<td>
<StatusBadge
color={initialValues?.is_paid ? 'primary' : 'warning'}
text={initialValues?.is_paid ? 'Lunas' : 'Belum Lunas'}
className={{
badge: 'w-fit whitespace-nowrap',
}}
/>
</td>
</tr>
<tr>
<th>Dokumen Pengajuan</th>
<th>:</th>
@@ -617,15 +536,21 @@ const ExpenseRequestContent = ({
<ul className='list-disc'>
{initialValues?.documents.map(
(requestDocument, requestDocumentIdx) => {
const path = requestDocument.path.startsWith(
'/'
)
? requestDocument.path.slice(1)
: requestDocument.path;
const documentUrl = `${S3_PUBLIC_BASE_URL}/${path}`;
return (
<li key={requestDocumentIdx}>
<Link
href={requestDocument.path}
href={documentUrl}
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 underline'
>
{requestDocument.name}{' '}
{requestDocument.path}{' '}
<Icon
icon='cuida:open-in-new-tab-outline'
width={12}
@@ -821,21 +746,6 @@ const ExpenseRequestContent = ({
onClick: confirmationModalRejectClickHandler,
}}
/>
<ConfirmationModal
ref={paidOffModal.ref}
type='success'
text='Apakah anda yakin ingin menandai biaya operasional ini sebagai lunas?'
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'success',
isLoading: isPaidOffLoading,
onClick: confirmationModalPaidOffClickHandler,
}}
/>
</>
);
};
+47 -693
View File
@@ -1,12 +1,8 @@
'use client';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
import useSWR from 'swr';
import {
CellContext,
@@ -19,11 +15,8 @@ import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import TextArea from '@/components/input/TextArea';
import Button from '@/components/Button';
import Modal, { useModal } from '@/components/Modal';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
@@ -35,65 +28,22 @@ import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import ExpensesFilterModal from '@/components/pages/expense/filter/ExpensesFilterModal';
import ExpenseTableSkeleton from '@/components/pages/expense/skeleton/ExpenseTableSkeleton';
import Dropdown from '@/components/dropdown/Dropdown';
import StatusBadge from '@/components/helper/StatusBadge';
import { Expense } from '@/types/api/expense';
import { ExpenseApi } from '@/services/api/expense';
import { cn, formatCurrency, formatDate } from '@/lib/helper';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
type ExpenseTableFilters = {
search: string;
nameSort: string;
transactionDate: string;
realizationDate: string;
locationId: string;
locationName: string;
vendorId: string;
vendorName: string;
category: string;
approvalStatus: string;
realizationStatus: string;
projectFlockId: string;
projectFlockName: string;
projectFlockKandangId: string;
projectFlockKandangName: string;
userId: string;
};
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' },
] as const satisfies OptionType<
'HEAD_AREA' | 'UNIT_VICE_PRESIDENT' | 'FINANCE' | 'REALISASI' | 'SELESAI'
>[];
type ApprovalStatusValue =
| 'HEAD_AREA'
| 'UNIT_VICE_PRESIDENT'
| 'FINANCE'
| 'REALISASI'
| 'SELESAI';
const isApprovalDateRequired = (status?: ApprovalStatusValue) =>
status === 'REALISASI' || status === 'SELESAI';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
paidOffClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Expense, unknown>;
deleteClickHandler: () => void;
paidOffClickHandler: () => void;
}) => {
const popoverId = `expense#${props.row.original.id}`;
const popoverAnchorName = `--anchor-expense#${props.row.original.id}`;
@@ -115,11 +65,6 @@ const RowOptionsMenu = ({
props.row.original.latest_approval.step_number === 4
: false;
const showPaidOffButton = props.row.original.latest_approval
? props.row.original.latest_approval.step_number >= 4 &&
!props.row.original.is_paid
: false;
return (
<div className='relative'>
<PopoverButton
@@ -187,28 +132,6 @@ const RowOptionsMenu = ({
</RequirePermission>
)}
{showPaidOffButton && (
<RequirePermission permissions='lti.expense.create.realization'>
<Button
onClick={() => {
paidOffClickHandler();
closePopover();
}}
variant='ghost'
color='none'
className='p-3 justify-start text-sm font-semibold w-full'
>
<Icon
icon='material-symbols:check-circle-outline'
width={20}
height={20}
className='text-success'
/>
Tandai Lunas
</Button>
</RequirePermission>
)}
<RequirePermission permissions='lti.expense.delete'>
<Button
onClick={() => {
@@ -230,32 +153,23 @@ const RowOptionsMenu = ({
};
const ExpensesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
reset: resetFilter,
} = useTableFilter<ExpenseTableFilters>({
} = useTableFilter({
initial: {
page: 1,
pageSize: 10,
search: '',
nameSort: '',
transactionDate: '',
realizationDate: '',
locationId: '',
locationName: '',
vendorId: '',
vendorName: '',
category: '',
approvalStatus: '',
realizationStatus: '',
projectFlockId: '',
projectFlockName: '',
projectFlockKandangId: '',
projectFlockKandangName: '',
userId: '',
},
paramMap: {
@@ -265,21 +179,9 @@ const ExpensesTable = () => {
transactionDate: 'transaction_date',
realizationDate: 'realization_date',
locationId: 'location_id',
locationName: 'location_name',
vendorId: 'vendor_id',
vendorName: 'vendor_name',
category: 'category',
approvalStatus: 'approval_status',
realizationStatus: 'realization_status',
projectFlockId: 'project_flock_id',
projectFlockName: 'project_flock_name',
projectFlockKandangId: 'project_flock_kandang_id',
projectFlockKandangName: 'project_flock_kandang_name',
userId: 'user_id',
},
persist: true,
storeName: 'expense-table',
});
const {
@@ -294,9 +196,6 @@ const ExpensesTable = () => {
const deleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const paidOffModal = useModal();
const bulkApproveFormModal = useModal();
const exportProgressInputModal = useModal();
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
@@ -307,17 +206,7 @@ const ExpensesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [isPaidOffLoading, setIsPaidOffLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [bulkApprovalStatus, setBulkApprovalStatus] =
useState<OptionType<ApprovalStatusValue> | null>(null);
const [bulkApprovalDate, setBulkApprovalDate] = useState('');
const [bulkApprovalNotes, setBulkApprovalNotes] = useState('');
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
@@ -442,7 +331,7 @@ const ExpensesTable = () => {
},
{
accessorFn: (row) => row.supplier.name ?? '-',
header: 'Uraian',
header: 'Vendor',
},
{
accessorKey: 'grand_total',
@@ -464,20 +353,6 @@ const ExpensesTable = () => {
<ExpenseStatusBadge approval={props.row.original.latest_approval} />
),
},
{
header: 'Status Lunas',
cell: (props) => {
return (
<StatusBadge
color={props.row.original.is_paid ? 'primary' : 'warning'}
text={props.row.original.is_paid ? 'Lunas' : 'Belum Lunas'}
className={{
badge: 'w-fit whitespace-nowrap',
}}
/>
);
},
},
{
header: 'Aksi',
cell: (props) => {
@@ -493,17 +368,11 @@ const ExpensesTable = () => {
deleteModal.openModal();
};
const paidOffClickHandler = () => {
setSelectedExpense(props.row.original);
paidOffModal.openModal();
};
return (
<RowOptionsMenu
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
props={props}
deleteClickHandler={deleteClickHandler}
paidOffClickHandler={paidOffClickHandler}
/>
);
},
@@ -521,45 +390,17 @@ const ExpensesTable = () => {
);
};
const resetBulkApproveForm = useCallback(() => {
setBulkApprovalStatus(null);
setBulkApprovalDate('');
setBulkApprovalNotes('');
}, []);
// const bulkApproveClickHandler = () => {
// approveModal.openModal();
// };
const openBulkApproveForm = useCallback(
(presetStatus?: ApprovalStatusValue) => {
resetBulkApproveForm();
if (presetStatus) {
const selectedStatus = approvalStatusOptions.find(
(option) => option.value === presetStatus
);
if (selectedStatus) {
setBulkApprovalStatus(selectedStatus);
}
}
bulkApproveFormModal.openModal();
},
[bulkApproveFormModal, resetBulkApproveForm]
);
// const bulkRejectClickHandler = () => {
// rejectModal.openModal();
// };
const bulkApproveClickHandler = () => {
openBulkApproveForm();
};
const bulkApproveHeadAreaClickHandler = () => {
openBulkApproveForm('HEAD_AREA');
};
const bulkApproveUnitVicePresidentClickHandler = () => {
openBulkApproveForm('UNIT_VICE_PRESIDENT');
};
const bulkApproveFinanceClickHandler = () => {
openBulkApproveForm('FINANCE');
setApprovalNotes('');
approveModal.openModal();
};
const bulkRejectClickHandler = () => {
@@ -567,65 +408,6 @@ const ExpensesTable = () => {
rejectModal.openModal();
};
const bulkApprovalDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkApprovalDate(e.target.value);
};
const bulkApprovalNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkApprovalNotes(e.target.value);
};
const resetExportProgressForm = useCallback(() => {
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 ExpenseApi.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 confirmationModalDeleteClickHandler = async () => {
setIsDeleteLoading(true);
@@ -645,29 +427,6 @@ const ExpensesTable = () => {
setIsDeleteLoading(false);
};
const confirmationModalPaidOffClickHandler = async () => {
setIsPaidOffLoading(true);
const paidOffResponse = await ExpenseApi.setExpensePaidOff(
selectedExpense?.id as number
);
if (isResponseSuccess(paidOffResponse)) {
refreshExpenses();
paidOffModal.closeModal();
toast.success('Berhasil menandai biaya operasional sebagai lunas!');
refreshExpenses();
} else {
paidOffModal.closeModal();
toast.error(
'Gagal menandai biaya operasional sebagai lunas!: ' +
paidOffResponse?.message
);
}
setIsPaidOffLoading(false);
};
const confirmationModalApproveClickHandler = async (notes: string) => {
setIsApproveLoading(true);
@@ -711,48 +470,6 @@ const ExpensesTable = () => {
setIsApproveLoading(false);
};
const bulkApproveSubmitHandler = async () => {
if (!bulkApprovalStatus) {
return;
}
if (isApprovalDateRequired(bulkApprovalStatus.value) && !bulkApprovalDate) {
toast.error('Tanggal realisasi wajib diisi.');
return;
}
if (!bulkApprovalNotes.trim()) {
toast.error('Catatan wajib diisi.');
return;
}
setIsApproveLoading(true);
const bulkApproveResponse = await ExpenseApi.bulkApprovals(
selectedRowIds,
bulkApprovalStatus.value,
isApprovalDateRequired(bulkApprovalStatus.value) ? bulkApprovalDate : '',
bulkApprovalNotes
);
if (isResponseSuccess(bulkApproveResponse)) {
refreshExpenses();
bulkApproveFormModal.closeModal();
toast.success(
`Berhasil approve ${selectedRowIds.length} data biaya operasional!`
);
resetBulkApproveForm();
setRowSelection({});
} else {
toast.error(
bulkApproveResponse?.message ??
`Gagal approve ${selectedRowIds.length} data biaya operasional!`
);
}
setIsApproveLoading(false);
};
const confirmationModalRejectClickHandler = async (notes: string) => {
setIsRejectLoading(true);
@@ -795,8 +512,17 @@ const ExpensesTable = () => {
setIsRejectLoading(false);
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('expense-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
// ===== FILTER MODAL HANDLERS =====
@@ -807,87 +533,28 @@ const ExpensesTable = () => {
const handleFilterSubmit = (values: {
transaction_date?: string | null;
realization_date?: string | null;
location?: { value: number; label: string } | null;
vendor?: { value: number; label: string } | null;
category?: OptionType<string> | null;
approval_status?: OptionType<string> | null;
realization_status?: OptionType<string> | null;
project_flock?: OptionType<number> | null;
project_flock_kandang?: OptionType<number> | null;
location_id?: string | null;
vendor_id?: string | null;
}) => {
updateFilter('transactionDate', values.transaction_date || '', true);
updateFilter('realizationDate', values.realization_date || '', true);
updateFilter(
'locationId',
values.location?.value ? String(values.location?.value) : '',
true
);
updateFilter(
'locationName',
values.location?.label ? String(values.location?.label) : '',
true
);
updateFilter(
'vendorId',
values.vendor?.value ? String(values.vendor?.value) : '',
true
);
updateFilter(
'vendorName',
values.vendor?.label ? String(values.vendor?.label) : '',
true
);
updateFilter('category', values.category?.value || '', true);
updateFilter('approvalStatus', values.approval_status?.value || '', true);
updateFilter(
'realizationStatus',
values.realization_status?.value || '',
true
);
updateFilter(
'projectFlockId',
values.project_flock?.value ? String(values.project_flock.value) : '',
true
);
updateFilter('projectFlockName', values.project_flock?.label || '', true);
updateFilter(
'projectFlockKandangId',
values.project_flock_kandang?.value
? String(values.project_flock_kandang.value)
: '',
true
);
updateFilter(
'projectFlockKandangName',
values.project_flock_kandang?.label || '',
true
);
updateFilter('transactionDate', values.transaction_date || '');
updateFilter('realizationDate', values.realization_date || '');
updateFilter('locationId', values.location_id || '');
updateFilter('vendorId', values.vendor_id || '');
};
const handleFilterReset = () => {
resetFilter();
updateFilter('transactionDate', '');
updateFilter('realizationDate', '');
updateFilter('locationId', '');
updateFilter('vendorId', '');
};
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await ExpenseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pengeluaran')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
// track sorting
useEffect(() => {
const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name');
if (!isNameSorted) {
updateFilter('nameSort', '', false);
updateFilter('nameSort', '');
} else {
updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc');
}
@@ -898,7 +565,7 @@ const ExpensesTable = () => {
<div className='w-full'>
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-col gap-3 flex-wrap'>
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.expense.create'>
<Button
href='/expense/add'
@@ -911,36 +578,14 @@ const ExpensesTable = () => {
</RequirePermission>
{selectedRowIds.length > 0 && (
<div className='flex flex-row gap-3 flex-wrap'>
<RequirePermission
permissions={[
'lti.expense.approve.head_area',
'lti.expense.approve.unit_vice_president',
'lti.expense.approve.finance',
'lti.expense.create.realization',
]}
>
<Button
variant='outline'
color='none'
onClick={bulkApproveClickHandler}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<Icon
icon='lucide-lab:farm'
width={20}
height={20}
className='text-success'
/>
Bulk Approve
</Button>
</RequirePermission>
<>
<hr className='w-px h-full border-none bg-base-content/10 sm:block hidden' />
<RequirePermission permissions='lti.expense.approve.head_area'>
<Button
variant='outline'
color='none'
onClick={bulkApproveHeadAreaClickHandler}
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnHeadArea}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
@@ -958,7 +603,7 @@ const ExpensesTable = () => {
<Button
variant='outline'
color='none'
onClick={bulkApproveUnitVicePresidentClickHandler}
onClick={bulkApproveClickHandler}
disabled={
!isAllSelectedRowLatestApprovalOnUnitVicePresident
}
@@ -978,7 +623,7 @@ const ExpensesTable = () => {
<Button
variant='outline'
color='none'
onClick={bulkApproveFinanceClickHandler}
onClick={bulkApproveClickHandler}
disabled={!isAllSelectedRowLatestApprovalOnFinance}
className='px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
@@ -1019,12 +664,12 @@ const ExpensesTable = () => {
Reject
</Button>
</RequirePermission>
</div>
</>
)}
</div>
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-start gap-3 flex-wrap'>
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
@@ -1053,69 +698,10 @@ const ExpensesTable = () => {
'search',
'nameSort',
'userId',
'locationName',
'vendorName',
'projectFlockName',
'projectFlockKandangName',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={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>
@@ -1180,21 +766,6 @@ const ExpensesTable = () => {
}}
/>
<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,
}}
/>
<ConfirmationModalWithNotes
ref={approveModal.ref}
type='success'
@@ -1233,227 +804,10 @@ const ExpensesTable = () => {
}}
/>
<Modal
ref={bulkApproveFormModal.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 Expense
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
bulkApproveFormModal.closeModal();
resetBulkApproveForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<SelectInput
label='Status Approval'
options={approvalStatusOptions as OptionType[]}
value={bulkApprovalStatus}
onChange={(val) => {
const nextValue = val as OptionType<ApprovalStatusValue> | null;
setBulkApprovalStatus(nextValue);
if (!isApprovalDateRequired(nextValue?.value)) {
setBulkApprovalDate('');
}
}}
placeholder='Pilih status approval'
isClearable
/>
{isApprovalDateRequired(bulkApprovalStatus?.value) && (
<DateInput
name='bulk_approval_date'
label='Tanggal Realisasi'
value={bulkApprovalDate}
onChange={bulkApprovalDateChangeHandler}
isNestedModal
required
/>
)}
<TextArea
name='bulk_approval_notes'
label='Catatan'
value={bulkApprovalNotes}
onChange={bulkApprovalNotesChangeHandler}
placeholder='Masukkan catatan approval...'
rows={4}
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
bulkApproveFormModal.closeModal();
resetBulkApproveForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={bulkApproveSubmitHandler}
isLoading={isApproveLoading}
disabled={
!bulkApprovalStatus ||
!bulkApprovalNotes.trim() ||
(isApprovalDateRequired(bulkApprovalStatus.value) &&
!bulkApprovalDate)
}
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>
<ExpensesFilterModal
ref={filterModal.ref}
onSubmit={handleFilterSubmit}
onReset={handleFilterReset}
initialValues={{
location:
tableFilterState.locationId && tableFilterState.locationName
? {
value: Number(tableFilterState.locationId),
label: tableFilterState.locationName,
}
: null,
vendor:
tableFilterState.vendorId && tableFilterState.vendorName
? {
value: Number(tableFilterState.vendorId),
label: tableFilterState.vendorName,
}
: null,
realization_date: tableFilterState.realizationDate,
transaction_date: tableFilterState.transactionDate,
category: tableFilterState.category
? {
value: tableFilterState.category,
label: tableFilterState.category,
}
: null,
approval_status: tableFilterState.approvalStatus
? approvalStatusOptions.find(
(item) => item.value === tableFilterState.approvalStatus
) || null
: null,
realization_status: tableFilterState.realizationStatus
? [
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
{ value: 'REALIZED', label: 'Sudah Realisasi' },
{ value: 'REJECTED', label: 'Ditolak' },
].find(
(item) => item.value === tableFilterState.realizationStatus
) || null
: null,
project_flock:
tableFilterState.projectFlockId && tableFilterState.projectFlockName
? {
value: Number(tableFilterState.projectFlockId),
label: tableFilterState.projectFlockName,
}
: null,
project_flock_kandang:
tableFilterState.projectFlockKandangId &&
tableFilterState.projectFlockKandangName
? {
value: Number(tableFilterState.projectFlockKandangId),
label: tableFilterState.projectFlockKandangName,
}
: null,
}}
/>
</>
);
@@ -3,60 +3,26 @@ import * as yup from 'yup';
export type ExpensesFilterType = {
transaction_date: string | null;
realization_date: string | null;
location: { value: number; label: string } | null;
vendor: { value: number; label: string } | null;
category: { value: string; label: string } | null;
approval_status: { value: string; label: string } | null;
realization_status: { value: string; label: string } | null;
project_flock: { value: number; label: string } | null;
project_flock_kandang: { value: number; label: string } | null;
location_id: string | null;
vendor_id: string | null;
};
export const ExpensesFilterSchema = yup.object({
transaction_date: yup.string().nullable(),
realization_date: yup.string().nullable(),
location: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
vendor: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
category: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
approval_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
realization_status: yup
.object({
value: yup.string().required(),
label: yup.string().required(),
})
.nullable(),
project_flock: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
project_flock_kandang: yup
.object({
value: yup.number().required(),
label: yup.string().required(),
})
.nullable(),
realization_date: yup
.string()
.nullable()
.test(
'is-greater-or-equal-transaction',
'Tanggal realisasi tidak boleh sebelum tanggal transaksi',
function (value) {
const { transaction_date } = this.parent;
if (!transaction_date || !value) return true;
return new Date(value) >= new Date(transaction_date);
}
),
location_id: yup.string().nullable(),
vendor_id: yup.string().nullable(),
});
export type ExpensesFilterValues = yup.InferType<typeof ExpensesFilterSchema>;
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { RefObject } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
@@ -11,11 +11,8 @@ import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProjectFlockApi } from '@/services/api/production';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
import {
ExpensesFilterSchema,
ExpensesFilterValues,
@@ -34,143 +31,64 @@ const ExpensesFilterModal = ({
onSubmit,
onReset,
}: ExpensesFilterModalProps) => {
const [selectedLocationId, setSelectedLocationId] = useState<string>(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const closeModalHandler = () => {
ref.current?.close();
};
const categoryOptions = [
{ value: 'BOP', label: 'BOP' },
{ value: 'NON-BOP', label: 'NON-BOP' },
];
const approvalStatusOptions = [
{ value: 'HEAD_AREA', label: 'Approval Head Area' },
{ value: 'UNIT_VICE_PRESIDENT', label: 'Approval Unit Vice President' },
{ value: 'FINANCE', label: 'Approval Finance' },
{ value: 'REALISASI', label: 'Realisasi' },
{ value: 'SELESAI', label: 'Selesai' },
{ value: 'DITOLAK', label: 'Ditolak' },
];
const realizationStatusOptions = [
{ value: 'NOT_REALIZED', label: 'Belum Realisasi' },
{ value: 'REALIZED', label: 'Sudah Realisasi' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: vendorOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreVendors,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const {
setInputValue: setProjectFlockInputValue,
rawData: projectFlocksRawData,
options: projectFlockOptions,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<ExpensesFilterValues>({
enableReinitialize: true,
initialValues: initialValues || {
transaction_date: null,
realization_date: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
location_id: null,
vendor_id: null,
},
validationSchema: ExpensesFilterSchema,
onSubmit: async (values) => {
onSubmit?.(values);
closeModalHandler();
},
onReset: () => {
onReset?.();
closeModalHandler();
},
});
useEffect(() => {
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.location]);
const locationValue = formik.values.location_id
? locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
: null;
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
transaction_date: null,
realization_date: null,
location: null,
vendor: null,
category: null,
approval_status: null,
realization_status: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const vendorValue = formik.values.vendor_id
? vendorOptions.find(
(opt) => String(opt.value) === formik.values.vendor_id
) || null
: null;
const locationChangeHandler = (val: OptionType | OptionType[] | null) => {
const value = val as OptionType | null;
formik.setFieldValue('location', value);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(value?.value ? String(value.value) : '');
const locationId =
val && !Array.isArray(val) ? (String(val.value) as string) : null;
formik.setFieldValue('location_id', locationId);
};
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 (
<Modal
ref={ref}
@@ -180,7 +98,7 @@ const ExpensesFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -203,41 +121,49 @@ const ExpensesFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<DateInput
name='transaction_date'
label='Tanggal Transaksi'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<DateInput
name='realization_date'
label='Tanggal Realisasi'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
<div className='flex flex-col'>
<span className='py-2 text-xs font-semibold'>Tanggal</span>
<div className='flex flex-row items-center gap-1.5'>
<DateInput
name='transaction_date'
placeholder='Tanggal Transaksi'
value={formik.values.transaction_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.transaction_date &&
!!formik.errors.transaction_date
}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='realization_date'
placeholder='Tanggal Realisasi'
value={formik.values.realization_date || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
isError={
formik.touched.realization_date &&
!!formik.errors.realization_date
}
/>
</div>
{formik.touched.realization_date &&
formik.errors.realization_date && (
<span className='text-xs text-error'>
{formik.errors.realization_date}
</span>
)}
</div>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.location}
value={locationValue}
onChange={locationChangeHandler}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
@@ -247,87 +173,14 @@ const ExpensesFilterModal = ({
label='Vendor'
placeholder='Pilih Vendor'
options={vendorOptions}
value={formik.values.vendor}
value={vendorValue}
onChange={vendorChangeHandler}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreVendors}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
onChange={(val) =>
formik.setFieldValue('category', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status BOP'
placeholder='Pilih Status BOP'
options={approvalStatusOptions}
value={formik.values.approval_status}
onChange={(val) =>
formik.setFieldValue('approval_status', val as OptionType | null)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Pencairan'
placeholder='Pilih Status Pencairan'
options={realizationStatusOptions}
value={formik.values.realization_status}
onChange={(val) =>
formik.setFieldValue(
'realization_status',
val as OptionType | null
)
}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue('project_flock', val as OptionType | null);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlockOptions}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isSearchable={true}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
val as OptionType | null
)
}
isClearable
isDisabled={!formik.values.project_flock}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
@@ -35,7 +35,6 @@ import { isResponseError } from '@/lib/api-helper';
import { LocationApi, SupplierApi } from '@/services/api/master-data';
import { Supplier } from '@/types/api/master-data/supplier';
import { ACCEPTED_FILE_TYPE } from '@/config/constant';
import { getExpenseListReturnTo } from '@/lib/expense-list-navigation';
import { cn } from '@/lib/helper';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
@@ -49,8 +48,6 @@ const ExpenseRealizationForm = ({
initialValues,
}: ExpenseRealizationFormProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = getExpenseListReturnTo(searchParams);
const [expenseFormErrorMessage, setExpenseFormErrorMessage] = useState('');
@@ -67,9 +64,9 @@ const ExpenseRealizationForm = ({
}
toast.success(createExpenseRes?.message as string);
router.push(returnTo);
router.push('/expense');
},
[initialValues?.id, returnTo, router]
[router]
);
const updateExpenseHandler = useCallback(
@@ -86,9 +83,9 @@ const ExpenseRealizationForm = ({
toast.success(updateExpenseRes?.message as string);
router.refresh();
router.push(returnTo);
router.push('/expense');
},
[returnTo, router]
[router]
);
const formik = useFormik<ExpenseRealizationFormValues>({
@@ -210,7 +207,7 @@ const ExpenseRealizationForm = ({
// add new realizations for each kandang
kandangs.forEach((kandangItem) => {
if (isNaN(Number(kandangItem.id))) return;
if (!kandangItem.id) return;
const existingRealization = formik.values.realizations?.find(
(realizationItem) => realizationItem.kandang_id === kandangItem.id
@@ -261,7 +258,7 @@ const ExpenseRealizationForm = ({
<section className='w-full'>
<header className='flex flex-col gap-4'>
<Button
href={returnTo}
href='/expense'
variant='link'
className='w-fit p-0 text-primary'
>
@@ -35,7 +35,6 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
setInputValue: setNonstockInputValue,
options: nonstockOptions,
isLoadingOptions: isLoadingNonstockOptions,
loadMore: loadMoreNonstocks,
} = useSelect<Nonstock>(
NonstockApi.basePath,
'id',
@@ -165,7 +164,6 @@ const ExpenseRealizationKandangDetailExpense: React.FC<
options={nonstockOptions}
isLoading={isLoadingNonstockOptions}
onInputChange={setNonstockInputValue}
onMenuScrollToBottom={loadMoreNonstocks}
className={{ wrapper: 'min-w-48' }}
isDisabled
/>
@@ -178,14 +178,12 @@ const ExpenseRequestForm = ({
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name');
const {
setInputValue: setVendorInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingVendorOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name');
const categoryChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -410,7 +408,6 @@ const ExpenseRequestForm = ({
options={locationOptions}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
onMenuScrollToBottom={loadMoreLocations}
isError={
formik.touched.location_id && Boolean(formik.errors.location_id)
}
@@ -455,7 +452,6 @@ const ExpenseRequestForm = ({
options={supplierOptions}
onInputChange={setVendorInputValue}
isLoading={isLoadingVendorOptions}
onMenuScrollToBottom={loadMoreSuppliers}
isError={
formik.touched.supplier_id && Boolean(formik.errors.supplier_id)
}
@@ -287,8 +287,8 @@ const ExpensePDF = ({ expense }: ExpensePDFProps) => {
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={ExpensePDFStyle.companyAddress}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={ExpensePDFStyle.doubleDivider} />
+138 -136
View File
@@ -1,6 +1,12 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
@@ -33,7 +39,7 @@ import PopoverContent from '@/components/popover/PopoverContent';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
FinanceTableFilterSchema,
FinanceTableFilterValues,
@@ -170,6 +176,9 @@ const RowOptionsMenu = ({
};
const FinanceTable = () => {
const { searchValue, setSearchValue, resetSearchValue } = useUiStore();
const previousPathRef = useRef<string | null>(null);
const {
state: tableFilterState,
updateFilter,
@@ -178,7 +187,7 @@ const FinanceTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
transactionTypes: '',
bankIds: '',
customerIds: '',
@@ -186,9 +195,6 @@ const FinanceTable = () => {
sortBy: '',
startDate: '',
endDate: '',
bankNames: '',
customerNames: '',
supplierNames: '',
},
paramMap: {
page: 'page',
@@ -201,9 +207,6 @@ const FinanceTable = () => {
startDate: 'start_date',
endDate: 'end_date',
},
excludeKeysFromUrl: ['bankNames', 'customerNames', 'supplierNames'],
persist: true,
storeName: 'finance-table',
});
// ===== FILTER MODAL STATE =====
@@ -232,7 +235,7 @@ const FinanceTable = () => {
// ===== Formik for Filter =====
const filterFormik = useFormik<FinanceTableFilterValues>({
initialValues: {
search: tableFilterState.search || '',
search: searchValue,
transaction_types: '',
bank_ids: '',
customer_ids: '',
@@ -242,47 +245,29 @@ const FinanceTable = () => {
end_date: '',
},
validationSchema: FinanceTableFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('search', values.search, true);
updateFilter('transactionTypes', values.transaction_types, true);
updateFilter('bankIds', values.bank_ids, true);
updateFilter('customerIds', values.customer_ids, true);
updateFilter('supplierIds', values.supplier_ids, true);
updateFilter('sortBy', values.sort_by, true);
updateFilter('startDate', values.start_date, true);
updateFilter('endDate', values.end_date, true);
// Save display names for restoration on modal reopen
const toNames = (val: OptionType | OptionType[] | null) =>
val
? (Array.isArray(val) ? val : [val])
.map((o) => String(o.label))
.join(',')
: '';
updateFilter('bankNames', toNames(selectedBank), true);
updateFilter('customerNames', toNames(selectedCustomerId), true);
updateFilter('supplierNames', toNames(selectedSupplierId), true);
enableReinitialize: true,
onSubmit: (values) => {
updateFilter('search', values.search);
setSearchValue(values.search);
updateFilter('transactionTypes', values.transaction_types);
updateFilter('bankIds', values.bank_ids);
updateFilter('customerIds', values.customer_ids);
updateFilter('supplierIds', values.supplier_ids);
updateFilter('sortBy', values.sort_by);
updateFilter('startDate', values.start_date);
updateFilter('endDate', values.end_date);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
updateFilter('search', '', true);
updateFilter('transactionTypes', '', true);
updateFilter('bankIds', '', true);
updateFilter('customerIds', '', true);
updateFilter('supplierIds', '', true);
updateFilter('sortBy', '', true);
updateFilter('startDate', '', true);
updateFilter('endDate', '', true);
updateFilter('bankNames', '', true);
updateFilter('customerNames', '', true);
updateFilter('supplierNames', '', true);
filterModal.closeModal();
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
},
});
@@ -335,10 +320,40 @@ const FinanceTable = () => {
});
}, [bankOptions, bankRawData]);
// ===== ACTIVE FILTERS COUNT =====
const activeFiltersCount = useMemo(() => {
let count = 0;
if (tableFilterState.transactionTypes) count += 1;
if (tableFilterState.bankIds) count += 1;
if (tableFilterState.customerIds) count += 1;
if (tableFilterState.supplierIds) count += 1;
if (tableFilterState.sortBy) count += 1;
if (tableFilterState.startDate) count += 1;
if (tableFilterState.endDate) count += 1;
return count;
}, [
tableFilterState.transactionTypes,
tableFilterState.bankIds,
tableFilterState.customerIds,
tableFilterState.supplierIds,
tableFilterState.sortBy,
tableFilterState.startDate,
tableFilterState.endDate,
]);
const hasFilters = activeFiltersCount > 0;
// ===== Handler =====
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const transactionTypeChangeHandler = (
val: OptionType | OptionType[] | null
@@ -454,74 +469,28 @@ const FinanceTable = () => {
};
const handleFilterModalOpen = () => {
// Restore transaction types from stored comma-separated IDs
const txIds = tableFilterState.transactionTypes
? tableFilterState.transactionTypes.split(',')
: [];
const restoredTxTypes = FINANCE_TRANSACTION_TYPE_OPTIONS.filter((opt) =>
txIds.includes(String(opt.value))
);
setSelectedTransactionType(restoredTxTypes.length ? restoredTxTypes : null);
// Restore banks from stored IDs and names
const bankIdList = tableFilterState.bankIds
? tableFilterState.bankIds.split(',')
: [];
const bankNameList = tableFilterState.bankNames
? tableFilterState.bankNames.split(',')
: [];
const restoredBanks = bankIdList.map((id, i) => ({
value: id,
label: bankNameList[i] || id,
}));
setSelectedBank(restoredBanks.length ? restoredBanks : null);
// Restore customers from stored IDs and names
const customerIdList = tableFilterState.customerIds
? tableFilterState.customerIds.split(',')
: [];
const customerNameList = tableFilterState.customerNames
? tableFilterState.customerNames.split(',')
: [];
const restoredCustomers = customerIdList.map((id, i) => ({
value: id,
label: customerNameList[i] || id,
}));
setSelectedCustomerId(restoredCustomers.length ? restoredCustomers : null);
// Restore suppliers from stored IDs and names
const supplierIdList = tableFilterState.supplierIds
? tableFilterState.supplierIds.split(',')
: [];
const supplierNameList = tableFilterState.supplierNames
? tableFilterState.supplierNames.split(',')
: [];
const restoredSuppliers = supplierIdList.map((id, i) => ({
value: id,
label: supplierNameList[i] || id,
}));
setSelectedSupplierId(restoredSuppliers.length ? restoredSuppliers : null);
// Restore sort by
const restoredSortBy =
sortByOptions.find(
(opt) => String(opt.value) === tableFilterState.sortBy
) || 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.sortBy || '',
start_date: tableFilterState.startDate || '',
end_date: tableFilterState.endDate || '',
});
filterModal.openModal();
filterFormik.validateForm();
};
const resetFilterHandler = () => {
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
filterFormik.resetForm();
updateFilter('search', '');
resetSearchValue();
updateFilter('transactionTypes', '');
updateFilter('bankIds', '');
updateFilter('customerIds', '');
updateFilter('supplierIds', '');
updateFilter('sortBy', '');
updateFilter('startDate', '');
updateFilter('endDate', '');
};
const confirmationModalDeleteClickHandler = async () => {
@@ -570,14 +539,10 @@ const FinanceTable = () => {
},
},
{
header: 'Tanggal Pembayaran',
header: 'Tanggal',
accessorFn: (finance: Finance) =>
formatDate(finance.payment_date, 'DD MMM YYYY'),
},
{
header: 'Tanggal Dibuat',
accessorFn: (finance) => formatDate(finance.created_at, 'DD MMM YYYY'),
},
{
header: 'Metode Pembayaran',
accessorKey: 'payment_method',
@@ -640,6 +605,27 @@ const FinanceTable = () => {
};
}, [dateErrorShown]);
useEffect(() => {
previousPathRef.current = window.location.pathname;
return () => {
const currentPath = window.location.pathname;
const isCurrentPathFinance = currentPath.includes('/finance');
const isPreviousPathFinance =
previousPathRef.current?.includes('/finance');
if (isPreviousPathFinance && !isCurrentPathFinance) {
resetSearchValue();
}
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
}, [resetSearchValue, dateErrorShown]);
return (
<>
<div className='w-full'>
@@ -701,19 +687,25 @@ const FinanceTable = () => {
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'bankNames',
'customerNames',
'supplierNames',
]}
<Button
variant='outline'
color='none'
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
className={cn(
'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all',
{
'border-primary-gradient text-primary': hasFilters,
}
)}
>
<Icon icon='heroicons:funnel' width={20} height={20} />
Filter
{hasFilters && (
<span className='w-5 h-5 text-white bg-[#FF3535] rounded-lg border border-base-300 flex items-center justify-center text-xs'>
{activeFiltersCount}
</span>
)}
</Button>
</div>
</div>
@@ -882,9 +874,19 @@ const FinanceTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
filterFormik.resetForm();
setSelectedTransactionType(null);
setSelectedBank(null);
setSelectedCustomerId(null);
setSelectedSupplierId(null);
setSelectedSortBy(null);
resetFilterHandler();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,6 +7,7 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { ColumnDef, ColumnSort, SortingState } from '@tanstack/react-table';
@@ -24,10 +25,7 @@ import { cn, formatNumber, formatDate, formatCurrency } from '@/lib/helper';
import { InventoryAdjustmentApi } from '@/services/api/inventory';
import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import toast from 'react-hot-toast';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryAdjustment } from '@/types/api/inventory/adjustment';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { TRANSACTION_SUBTYPE_OPTIONS } from '@/config/constant';
@@ -40,89 +38,27 @@ import {
AdjustmentFilterType,
} from '@/components/pages/inventory/adjustment/filter/AdjustmentFilter';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { CellContext } from '@tanstack/react-table';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<InventoryAdjustment, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `adjustment#${props.row.original.id}`;
const popoverAnchorName = `--anchor-adjustment#${props.row.original.id}`;
const closePopover = () => {
document.getElementById(popoverId)?.hidePopover();
};
return (
<div className='relative'>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
anchorName={popoverAnchorName}
position={popoverPosition === 'bottom' ? 'bottom-start' : 'left'}
className='w-full max-w-40 rounded-xl border border-base-content/5 shadow-sm'
>
<div className='flex flex-col bg-base-100 rounded-xl'>
<RequirePermission permissions='lti.inventory.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
);
};
const InventoryAdjustmentTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
productCategorySort: string;
productSort: string;
warehouseSort: string;
stockSort: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
transactionTypeFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
productCategorySort: '',
productSort: '',
warehouseSort: '',
stockSort: '',
productFilter: undefined,
warehouseFilter: undefined,
transactionTypeFilter: undefined,
productFilter: '',
warehouseFilter: '',
transactionTypeFilter: '',
},
paramMap: {
page: 'page',
@@ -135,8 +71,6 @@ const InventoryAdjustmentTable = () => {
warehouseFilter: 'warehouse_id',
transactionTypeFilter: 'transaction_type',
},
persist: true,
storeName: 'inventory-adjustment-table',
});
// ===== FILTER MODAL STATE =====
@@ -145,27 +79,22 @@ const InventoryAdjustmentTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<AdjustmentFilterType>({
initialValues: {
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
transaction_type: tableFilterState.transactionTypeFilter,
product_id: null,
warehouse_id: null,
transaction_type: null,
},
validationSchema: AdjustmentFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
updateFilter(
'transactionTypeFilter',
values.transaction_type || undefined,
true
);
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
updateFilter('transactionTypeFilter', values.transaction_type || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
updateFilter('transactionTypeFilter', undefined, true);
filterModal.closeModal();
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
updateFilter('transactionTypeFilter', '');
},
});
@@ -204,68 +133,85 @@ const InventoryAdjustmentTable = () => {
}, []);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('warehouse', val);
};
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
const handleFilterTransactionTypeChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('transaction_type', val);
};
const handleFilterTransactionTypeChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const type = val as OptionType | null;
const typeValue = type?.value ? String(type.value) : null;
formik.setFieldValue('transaction_type', typeValue);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
const transactionTypeValue = useMemo(() => {
if (!formik.values.transaction_type) return null;
return (
transactionTypeOptions.find(
(opt) => String(opt.value) === formik.values.transaction_type
) || null
);
}, [formik.values.transaction_type, transactionTypeOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
transaction_type: tableFilterState.transactionTypeFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const {
data: inventoryAdjustments,
isLoading,
mutate: refreshAdjustments,
} = useSWR(
const { data: inventoryAdjustments, isLoading } = useSWR(
`${InventoryAdjustmentApi.basePath}${getTableFilterQueryString()}`,
InventoryAdjustmentApi.getAllFetcher
);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await InventoryAdjustmentApi.delete(
selectedAdjustment?.id as number
);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Adjustment!');
refreshAdjustments();
} else {
toast.error(response?.message || 'Failed to delete Adjustment');
}
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedAdjustment, setSelectedAdjustment] = useState<
InventoryAdjustment | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-adjustment-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const inventoryAdjustmentsColumns: ColumnDef<InventoryAdjustment>[] = useMemo(
@@ -368,39 +314,8 @@ const InventoryAdjustmentTable = () => {
header: 'Oleh',
accessorFn: (row) => row.created_user?.name ?? '-',
},
{
id: 'actions',
header: 'Aksi',
cell: (props: CellContext<InventoryAdjustment, unknown>) => {
const currentPageSize =
props.table.getPaginationRowModel().rows.length;
const currentPageRows = props.table.getPaginationRowModel().flatRows;
const currentRowRelativeIndex =
currentPageRows.findIndex((r) => r.id === props.row.id) + 1;
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedAdjustment(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedAdjustment,
]
[tableFilterState.pageSize, tableFilterState.page]
);
const updateSortingFilter = useCallback(
@@ -486,8 +401,6 @@ const InventoryAdjustmentTable = () => {
'productSort',
'warehouseSort',
'stockSort',
'productName',
'warehouseName',
]}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
@@ -577,7 +490,7 @@ const InventoryAdjustmentTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={formik.values.product}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -589,7 +502,7 @@ const InventoryAdjustmentTable = () => {
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={formik.values.warehouse}
value={warehouseIdValue}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
@@ -601,7 +514,7 @@ const InventoryAdjustmentTable = () => {
label='Tipe Transaksi'
placeholder='Pilih Tipe Transaksi'
options={transactionTypeOptions}
value={formik.values.transaction_type}
value={transactionTypeValue}
onChange={handleFilterTransactionTypeChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -611,9 +524,13 @@ const InventoryAdjustmentTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -627,21 +544,6 @@ const InventoryAdjustmentTable = () => {
</div>
</form>
</Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Adjustment ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</>
);
};
@@ -1,23 +1,13 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const AdjustmentFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
transaction_type: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const AdjustmentFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
transaction_type: string().nullable(),
});
export type AdjustmentFilterType = {
product?: OptionType<string>;
warehouse?: OptionType<string>;
transaction_type?: OptionType<string>;
product_id: string | null;
warehouse_id: string | null;
transaction_type: string | null;
};
@@ -15,7 +15,7 @@ import {
InventoryAdjustmentFormSchema,
InventoryAdjustmentFormValues,
} from '@/components/pages/inventory/adjustment/form/InventoryAdjustmentForm.schema';
import { LocationApi } from '@/services/api/master-data';
import { KandangApi, LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
@@ -32,6 +32,8 @@ import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { Location } from '@/types/api/master-data/location';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
import { Kandang } from '@/types/api/master-data/kandang';
import { Product } from '@/types/api/master-data/product';
import { ProjectFlockKandangLookup } from '@/types/api/production/project-flock';
import { BaseApiResponse } from '@/types/api/api-general';
@@ -117,19 +119,40 @@ const InventoryAdjustmentForm = ({
}
);
const { rawData: approvedProjectFlockKandangsRawData } =
useSelect<ProjectFlockKandang>(
ProjectFlockKandangApi.basePath,
'id',
'id',
'search',
{
step_name: 'Disetujui',
limit: '100',
}
);
const approvedProjectFlockKandangs = useMemo(() => {
if (
approvedProjectFlockKandangsRawData &&
'data' in approvedProjectFlockKandangsRawData
) {
return approvedProjectFlockKandangsRawData.data as ProjectFlockKandang[];
}
return [];
}, [approvedProjectFlockKandangsRawData]);
const {
options: projectFlockKandangOptions,
loadMore: loadMoreProjectFlockKandangs,
setInputValue: setProjectFlockKandangInputValue,
isLoadingOptions: isLoadingProjectFlockKandangOptions,
} = useSelect(
selectedProjectFlock ? ProjectFlockKandangApi.basePath : '',
'kandang.id',
'kandang.name',
setInputValue: setKandangInputValue,
options: kandangOptionsFromApi,
isLoadingOptions: isLoadingKandangOptions,
loadMore: loadMoreKandangs,
} = useSelect<Kandang>(
selectedProjectFlock ? KandangApi.basePath : '',
'id',
'name',
'search',
{
step_name: 'Disetujui',
project_flock_id: String(selectedProjectFlock?.value),
location_id: selectedProjectFlockLocationId,
}
);
@@ -199,6 +222,26 @@ const InventoryAdjustmentForm = ({
return (product?.flags as string[]) || [];
}, [selectedProduct, productOptions]);
const kandangOptions = useMemo(() => {
let options: OptionType[] = [];
if (selectedProjectFlock) {
const approvedKandangIds = approvedProjectFlockKandangs
.filter((pfk) => pfk.project_flock_id === selectedProjectFlock.value)
.map((pfk) => pfk.kandang_id);
options = kandangOptionsFromApi.filter((kandang) =>
approvedKandangIds.includes(kandang.value as number)
);
}
return options;
}, [
selectedProjectFlock,
kandangOptionsFromApi,
approvedProjectFlockKandangs,
]);
const formikInitialValues = useMemo<Partial<InventoryAdjustmentFormValues>>(
() => ({
location: null,
@@ -650,10 +693,10 @@ const InventoryAdjustmentForm = ({
label='Kandang'
value={selectedKandang}
onChange={kandangChangeHandler}
onInputChange={setProjectFlockKandangInputValue}
options={projectFlockKandangOptions}
onMenuScrollToBottom={loadMoreProjectFlockKandangs}
isLoading={isLoadingProjectFlockKandangOptions}
onInputChange={setKandangInputValue}
options={kandangOptions}
onMenuScrollToBottom={loadMoreKandangs}
isLoading={isLoadingKandangOptions}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -1,6 +1,13 @@
'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 { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
import { useFormik } from 'formik';
@@ -13,8 +20,7 @@ import { WarehouseApi, ProductApi } from '@/services/api/master-data';
import { cn } from '@/lib/helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import toast from 'react-hot-toast';
import { useUiStore } from '@/stores/ui/ui.store';
import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
@@ -35,11 +41,9 @@ import {
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
deleteClickHandler,
}: {
popoverPosition: 'bottom' | 'top';
props: CellContext<Movement, unknown>;
deleteClickHandler: () => void;
}) => {
const popoverId = `movement#${props.row.original.id}`;
const popoverAnchorName = `--anchor-movement#${props.row.original.id}`;
@@ -79,20 +83,6 @@ const RowOptionsMenu = ({
Detail
</Button>
</RequirePermission>
<RequirePermission permissions='lti.inventory.transfer.delete'>
<Button
onClick={() => {
deleteClickHandler();
closePopover();
}}
variant='ghost'
color='error'
className='p-3 justify-start text-sm font-semibold w-full focus-visible:text-error-content hover:text-error-content'
>
<Icon icon='mdi:delete-outline' width={20} height={20} />
Delete
</Button>
</RequirePermission>
</div>
</PopoverContent>
</div>
@@ -100,21 +90,20 @@ const RowOptionsMenu = ({
};
const MovementTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
productFilter?: OptionType<string>;
warehouseFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
productFilter: undefined,
warehouseFilter: undefined,
productFilter: '',
warehouseFilter: '',
},
paramMap: {
page: 'page',
@@ -122,8 +111,6 @@ const MovementTable = () => {
productFilter: 'product_id',
warehouseFilter: 'warehouse_id',
},
persist: true,
storeName: 'movement-table',
});
// ===== FILTER MODAL STATE =====
@@ -132,20 +119,19 @@ const MovementTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<MovementFilterType>({
initialValues: {
product: tableFilterState.productFilter,
warehouse: tableFilterState.warehouseFilter,
product_id: null,
warehouse_id: null,
},
validationSchema: MovementFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('productFilter', values.product || undefined, true);
updateFilter('warehouseFilter', values.warehouse || undefined, true);
updateFilter('productFilter', values.product_id || '');
updateFilter('warehouseFilter', values.warehouse_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productFilter', undefined, true);
updateFilter('warehouseFilter', undefined, true);
filterModal.closeModal();
updateFilter('productFilter', '');
updateFilter('warehouseFilter', '');
},
});
@@ -176,59 +162,67 @@ const MovementTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product', val);
};
const handleFilterProductChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const product = val as OptionType | null;
const productId = product?.value ? String(product.value) : null;
formik.setFieldValue('product_id', productId);
},
[formik]
);
const handleFilterWarehouseChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('warehouse', val);
};
const handleFilterWarehouseChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const warehouse = val as OptionType | null;
const warehouseId = warehouse?.value ? String(warehouse.value) : null;
formik.setFieldValue('warehouse_id', warehouseId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productIdValue = useMemo(() => {
if (!formik.values.product_id) return null;
return (
productOptions.find(
(opt) => String(opt.value) === formik.values.product_id
) || null
);
}, [formik.values.product_id, productOptions]);
const warehouseIdValue = useMemo(() => {
if (!formik.values.warehouse_id) return null;
return (
warehouseOptions.find(
(opt) => String(opt.value) === formik.values.warehouse_id
) || null
);
}, [formik.values.warehouse_id, warehouseOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
product: tableFilterState.productFilter ?? undefined,
warehouse: tableFilterState.warehouseFilter ?? undefined,
});
filterModal.openModal();
formik.validateForm();
};
const [sorting, setSorting] = useState<SortingState>([]);
const [selectedMovement, setSelectedMovement] = useState<
Movement | undefined
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const singleDeleteModal = useModal();
const {
data: movements,
isLoading,
mutate: refreshMovements,
} = useSWR(
const { data: movements, isLoading } = useSWR(
`${MovementApi.basePath}${getTableFilterQueryString()}`,
MovementApi.getAllFetcher
);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const response = await MovementApi.delete(selectedMovement?.id as number);
singleDeleteModal.closeModal();
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Movement!');
refreshMovements();
} else {
toast.error(response?.message || 'Failed to delete Movement');
}
};
useEffect(() => {
setTableState('movement-table', pathname);
}, [pathname, setTableState]);
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(
@@ -281,27 +275,16 @@ const MovementTable = () => {
const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2;
const deleteClickHandler = () => {
setSelectedMovement(props.row.original);
singleDeleteModal.openModal();
};
return (
<RowOptionsMenu
props={props}
deleteClickHandler={deleteClickHandler}
popoverPosition={isLast2Rows ? 'top' : 'bottom'}
/>
);
},
},
],
[
tableFilterState.pageSize,
tableFilterState.page,
singleDeleteModal,
setSelectedMovement,
]
[tableFilterState.pageSize, tableFilterState.page]
);
return (
@@ -427,7 +410,7 @@ const MovementTable = () => {
label='Produk'
placeholder='Pilih Produk'
options={productOptions}
value={formik.values.product}
value={productIdValue}
onChange={handleFilterProductChange}
onInputChange={setProductInputValue}
isLoading={isLoadingProductOptions}
@@ -439,7 +422,7 @@ const MovementTable = () => {
label='Gudang'
placeholder='Pilih Gudang'
options={warehouseOptions}
value={formik.values.warehouse}
value={warehouseIdValue}
onChange={handleFilterWarehouseChange}
onInputChange={setWarehouseInputValue}
isLoading={isLoadingWarehouseOptions}
@@ -452,9 +435,13 @@ const MovementTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -468,21 +455,6 @@ const MovementTable = () => {
</div>
</form>
</Modal>
<ConfirmationModal
ref={singleDeleteModal.ref}
type='error'
text={`Apakah anda yakin ingin menghapus data Movement ini?`}
secondaryButton={{
text: 'Tidak',
}}
primaryButton={{
text: 'Ya',
color: 'error',
isLoading: isDeleteLoading,
onClick: singleDeleteHandler,
}}
/>
</>
);
};
@@ -1,18 +1,11 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const MovementFilterSchema = Yup.object().shape({
product: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
warehouse: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const MovementFilterSchema = object().shape({
product_id: string().nullable(),
warehouse_id: string().nullable(),
});
export type MovementFilterType = {
product?: OptionType<string>;
warehouse?: OptionType<string>;
product_id: string | null;
warehouse_id: string | null;
};
@@ -82,7 +82,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: number;
warehouse_name: string;
quantity: number;
transfer_available_qty?: number;
}
// ===== USE SELECT HOOKS =====
@@ -380,8 +379,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: formik.values.source_warehouse_id
? formik.values.source_warehouse_id.toString()
: '',
transfer_context: 'inventory_transfer',
stock_mode: 'exclude_chickin',
}
);
@@ -394,7 +391,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
warehouse_id: pw.warehouse.id,
warehouse_name: pw.warehouse.name,
quantity: pw.quantity,
transfer_available_qty: pw.transfer_available_qty,
}))
: [];
}, [productWarehouses]);
@@ -838,22 +834,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
}, [formik.values.products, formik.values.deliveries]);
const getAvailableStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return (
productWarehouse?.transfer_available_qty ??
productWarehouse?.quantity ??
0
);
},
[productWarehouseOptions, type]
);
const getTotalStock = useCallback(
(productId: number) => {
if (type === 'detail') return 0;
const productWarehouse = productWarehouseOptions.find(
@@ -864,16 +844,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
[productWarehouseOptions, type]
);
const hasAvailableQty = useCallback(
(productId: number) => {
const productWarehouse = productWarehouseOptions.find(
(pw) => pw.product_id === productId
);
return productWarehouse?.transfer_available_qty !== undefined;
},
[productWarehouseOptions]
);
const getProductQtyBottomLabel = useCallback(
(productIdx: number) => {
if (type === 'detail') return undefined;
@@ -881,31 +851,16 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return undefined;
const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0;
const remainingStock = availableStock - requestedQty;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > 0) {
if (isAyamProduct) {
return `Sisa: ${formatNumber(remainingStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Sisa: ${formatNumber(remainingStock)}`;
}
if (isAyamProduct) {
return `Tersedia: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)})`;
}
return `Tersedia: ${formatNumber(availableStock)}`;
},
[
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
[formik.values.products, getAvailableStock, type]
);
const getDeliveryProductQtyBottomLabel = useCallback(
@@ -967,26 +922,15 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
if (!product || !product.product_id) return null;
const availableStock = getAvailableStock(product.product_id);
const totalStock = getTotalStock(product.product_id);
const requestedQty = Number(product.product_qty) || 0;
const isAyamProduct = hasAvailableQty(product.product_id);
if (requestedQty > availableStock) {
if (isAyamProduct) {
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)} (Total: ${formatNumber(totalStock)}, terpakai untuk chickin: ${formatNumber(totalStock - availableStock)})`;
}
return `Qty melebihi stok tersedia! Maksimal: ${formatNumber(availableStock)}`;
}
return null;
},
[
formik.values.products,
getAvailableStock,
getTotalStock,
hasAvailableQty,
type,
]
[formik.values.products, getAvailableStock, type]
);
const validateDeliveryQty = useCallback(
@@ -4,23 +4,17 @@ import Button from '@/components/Button';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import Table from '@/components/Table';
import RequirePermission from '@/components/helper/RequirePermission';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Modal, { useModal } from '@/components/Modal';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatNumber } from '@/lib/helper';
import { InventoryProductApi } from '@/services/api/inventory';
import { ProductCategoryApi } from '@/services/api/master-data';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import { InventoryProduct } from '@/types/api/inventory/product';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import InventoryProductTableSkeleton from '@/components/pages/inventory/product/skeleton/InventoryProductTableSkeleton';
@@ -77,79 +71,25 @@ const RowOptionsMenu = ({
};
const InventoryProductTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
categoryFilter: undefined,
},
paramMap: {
page: 'page',
pageSize: 'limit',
categoryFilter: 'product_category_id',
},
persist: true,
storeName: 'inventory-product-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FORMIK SETUP =====
const formik = useFormik<{ category?: OptionType<string> }>({
initialValues: { category: tableFilterState.categoryFilter },
validationSchema: Yup.object().shape({
category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
}),
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category || undefined, true);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', undefined, true);
filterModal.closeModal();
},
});
// ===== CATEGORY OPTIONS =====
const {
setInputValue: setCategoryInputValue,
options: categoryOptions,
isLoadingOptions: isLoadingCategoryOptions,
loadMore: loadMoreCategories,
} = useSelect<ProductCategory>(
filterModal.open ? ProductCategoryApi.basePath : null,
'id',
'name',
'search'
);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
formik.setValues({
category: tableFilterState.categoryFilter ?? undefined,
});
filterModal.openModal();
};
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const [sorting, setSorting] = useState<SortingState>([]);
const { data: inventoryProducts, isLoading } = useSWR(
@@ -157,8 +97,17 @@ const InventoryProductTable = () => {
InventoryProductApi.getAllFetcher
);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('inventory-product-table', pathname);
}, [pathname, setTableState]);
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(
@@ -233,163 +182,96 @@ const InventoryProductTable = () => {
);
return (
<>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
<div className='w-full'>
{/* Header Section */}
<div className='w-full p-3 flex flex-row justify-between gap-3 flex-wrap border-b border-base-content/10'>
{/* Action Buttons */}
<div className='w-fit flex flex-row gap-3 flex-wrap'>
<RequirePermission permissions='lti.inventory.product_stock.create'>
<Button
href='/inventory/product/add'
color='primary'
className='px-3 py-2.5 w-fit text-sm text-base-100 rounded-lg shadow-sm'
>
<Icon icon='heroicons:plus' width={20} height={20} />
Add Product
</Button>
</RequirePermission>
</div>
{/* Search and Filter */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
{/* Search */}
<div className='flex flex-1 flex-row justify-start sm:justify-end items-center gap-3 flex-wrap'>
<DebouncedTextInput
name='search'
placeholder='Search'
value={tableFilterState.search ?? ''}
onChange={searchChangeHandler}
startAdornment={
<Icon icon='heroicons:magnifying-glass' width={20} height={20} />
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:magnifying-glass'
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
className={{
wrapper: 'w-full min-w-24 max-w-3xs',
inputWrapper: 'rounded-xl! shadow-button-soft',
input:
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
</div>
</div>
{/* Table Section */}
<div className='flex flex-col mb-4'>
{isLoading ? (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
) : !isResponseSuccess(inventoryProducts) ||
inventoryProducts.data?.length === 0 ? (
<div className='p-3'>
<InventoryProductTableSkeleton
columns={columns}
icon={
<Icon
icon='heroicons:document-text'
className='text-white'
width={20}
height={20}
/>
}
/>
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
) : (
<Table<InventoryProduct>
data={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.data
: []
}
columns={columns}
pageSize={tableFilterState.pageSize}
page={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.page
: 0
}
totalItems={
isResponseSuccess(inventoryProducts)
? inventoryProducts?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
sorting={sorting}
setSorting={setSorting}
className={{
containerClassName: cn('p-3 mb-0'),
headerColumnClassName: 'text-nowrap',
}}
/>
)}
</div>
{/* 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>
</>
</div>
);
};
@@ -1,16 +1,8 @@
'use client';
import Card from '@/components/Card';
import { OptionType } from '@/components/input/SelectInput';
import { FormHeader } from '@/components/helper/form/FormHeader';
import ButtonFilter from '@/components/helper/ButtonFilter';
import RequirePermission from '@/components/helper/RequirePermission';
import { useModal } from '@/components/Modal';
import StockLogFilterModal from '@/components/pages/inventory/product/detail/StockLogFilterModal';
import StockLogTable from '@/components/pages/inventory/product/detail/StockLogTable';
import StockProductWarehouseTable from '@/components/pages/inventory/product/detail/StockProductWarehouseTable';
import { formatCurrency, formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { InventoryProduct } from '@/types/api/inventory/product';
import { useMemo } from 'react';
@@ -19,34 +11,17 @@ const InventoryProductDetail = ({
}: {
inventoryProduct?: InventoryProduct;
}) => {
const filterModal = useModal();
const { state: filterState, updateFilter } = useTableFilter<{
warehouse_ids: OptionType<number>[];
}>({
initial: {
warehouse_ids: [],
},
persist: true,
storeName: 'inventory-product-stock-log-filter',
});
const filteredProductWarehouses = useMemo(() => {
const warehouses = inventoryProduct?.product_warehouses ?? [];
if (!filterState.warehouse_ids?.length) return warehouses;
const selectedIds = new Set(filterState.warehouse_ids.map((w) => w.value));
return warehouses.filter((pw) => selectedIds.has(pw.warehouse_id));
}, [inventoryProduct?.product_warehouses, filterState.warehouse_ids]);
const filterSubmitHandler = (values: {
warehouse_ids: OptionType<number>[];
}) => {
updateFilter('warehouse_ids', values.warehouse_ids, true);
};
const filterResetHandler = () => {
updateFilter('warehouse_ids', [], true);
};
const stockLogs = useMemo(() => {
return (
inventoryProduct?.product_warehouses?.flatMap((warehouse) =>
warehouse.stock_logs.map((log) => ({
...log,
warehouse_name: warehouse.warehouse_name,
warehouse_id: warehouse.warehouse_id,
}))
) || []
);
}, [inventoryProduct]);
return (
<div className='flex flex-col gap-4 p-4'>
@@ -139,29 +114,7 @@ const InventoryProductDetail = ({
productWarehouseStock={inventoryProduct?.product_warehouses ?? []}
/>
<RequirePermission permissions={'lti.inventory.stock_log.list'}>
<div className='flex justify-end'>
<ButtonFilter
values={{ warehouse_ids: filterState.warehouse_ids }}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
</div>
{filteredProductWarehouses.map((productWarehouse) => (
<StockLogTable
key={productWarehouse.id}
productWarehouse={productWarehouse}
/>
))}
</RequirePermission>
<StockLogFilterModal
ref={filterModal.ref}
productWarehouses={inventoryProduct?.product_warehouses ?? []}
initialValues={{ warehouse_ids: filterState.warehouse_ids }}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
<StockLogTable stockLogs={stockLogs} />
</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 Table from '@/components/Table';
import { isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper';
import { StockLogApi } from '@/services/api/inventory';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock, StockLog } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
import { FileDown } from 'lucide-react';
import toast from 'react-hot-toast';
import { useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
const stockLogTableColumns: (warehouseName: string) => ColumnDef<StockLog>[] = (
warehouseName
) => [
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
cell: warehouseName,
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
];
import { StockLog } from '@/types/api/inventory/product';
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 (
<div ref={containerRef}>
<Card
title={`Informasi Stock Produk - ${productWarehouse.warehouse_name}`}
collapsible
variant='bordered'
<Card
title='Informasi Stock Produk'
collapsible
variant='bordered'
className={{
wrapper: 'w-full',
}}
>
<Table<StockLog>
data={stockLogs}
columns={[
{
header: 'ID',
accessorKey: 'id',
},
{
header: 'Tanggal',
accessorKey: 'created_at',
cell: (props) => {
return formatDate(props.row.original.created_at, 'DD-MMM-yyyy');
},
},
{
header: 'Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Stock Akhir',
accessorKey: 'stock',
cell: (props) => {
return formatNumber(props.row.original.stock);
},
},
{
header: 'Peningkatan',
accessorKey: 'increase',
cell: (props) => {
return formatNumber(props.row.original.increase);
},
},
{
header: 'Penurunan',
accessorKey: 'decrease',
cell: (props) => {
return formatNumber(props.row.original.decrease);
},
},
{
header: 'Jenis Transaksi',
accessorKey: 'loggable_type',
cell: (props) => {
return props.row.original.loggable_type
? formatTitleCase(props.row.original.loggable_type)
: '-';
},
},
{
header: 'Catatan',
accessorKey: 'notes',
cell: (props) => {
return props.row.original.notes ? props.row.original.notes : '-';
},
},
{
header: 'Oleh',
accessorKey: 'created_user.name',
},
]}
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'>
<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>
/>
</Card>
);
};
@@ -1,42 +1,13 @@
import Card from '@/components/Card';
import Table from '@/components/Table';
import { formatNumber } from '@/lib/helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { ProductWarehouseStock } from '@/types/api/inventory/product';
import { ColumnDef } from '@tanstack/react-table';
const stockProductWarehouseTableColumns: ColumnDef<ProductWarehouseStock>[] = [
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
];
const StockProductWarehouseTable = ({
productWarehouseStock,
}: {
productWarehouseStock?: ProductWarehouseStock[];
}) => {
const { state: tableFilterState, setPage, setPageSize } = useTableFilter();
return (
<Card
title='Informasi Gudang'
@@ -48,14 +19,32 @@ const StockProductWarehouseTable = ({
>
<Table<ProductWarehouseStock>
data={productWarehouseStock ?? []}
columns={stockProductWarehouseTableColumns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page ?? 0}
totalItems={productWarehouseStock?.length ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
columns={[
{
header: 'Nama Gudang',
accessorKey: 'warehouse_name',
},
{
header: 'Lokasi',
accessorKey: 'location',
cell: (props) => {
return props.row.original.location != null
? props.row.original.location.name
: '-';
},
},
{
header: 'Stok',
accessorFn(row) {
return row.current_stock;
},
cell: (props) => {
return formatNumber(props.row.original.current_stock);
},
},
]}
className={{
containerClassName: 'mt-6 mb-0',
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',
@@ -199,9 +199,6 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
};
}
})
@@ -371,9 +368,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
const currentProducts = deliveryOrderValues?.find(
(product) => product.id == id
);
setSelectedDeliveryProduct(currentProducts ?? values ?? null);
setSelectedDeliveryProduct(values ?? currentProducts ?? null);
if (id) {
setStep(2);
}
@@ -435,9 +430,6 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
'yyyy-MM-DD'
),
vehicle_number: product.vehicle_number,
weight_per_convertion: parseFloat(
String(product.weight_per_convertion ?? 0)
),
};
}
})
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useCallback, useMemo } from 'react';
import { RefObject, useMemo } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -10,38 +10,22 @@ import SelectInput, {
useSelect,
} from '@/components/input/SelectInput';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
MarketingFilterSchema,
} from '@/components/pages/marketing/filter/MarketingFilter';
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
onSubmit?: (values: MarketingFilter) => void;
onReset?: () => void;
initialValues?: {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
}
const MarketingFilterModal = ({
ref,
onSubmit,
onReset,
initialValues,
}: MarketingFilterModal) => {
const closeModalHandler = () => {
ref.current?.close();
@@ -49,35 +33,51 @@ const MarketingFilterModal = ({
// ===== OPTIONS =====
const {
options: productsOptions,
rawData: productsRawData,
isLoadingOptions: isLoadingProductsOptions,
setInputValue: setProductsInputValue,
loadMore: loadMoreProducts,
} = useSelect<Product>(ProductApi.basePath, 'id', 'name', 'search', {
include_all: 'true',
} = useSelect<BaseMarketing>(MarketingApi.basePath, 'id', 'so_number', '', {
limit: 'limit',
});
const productsOptions = useMemo(() => {
if (!productsRawData || !isResponseSuccess(productsRawData)) return [];
const productsMap = new Map<number, { value: number; label: string }>();
productsRawData.data.forEach((deliveryOrder: BaseMarketing) => {
deliveryOrder.sales_order?.forEach((so: BaseSalesOrder) => {
const product = so.product_warehouse?.product;
if (product?.id && product?.name) {
productsMap.set(product.id, {
value: product.id,
label: product.name,
});
}
});
});
return Array.from(productsMap.values());
}, [productsRawData]);
const {
options: customersOptions,
isLoadingOptions: isLoadingCustomersOptions,
setInputValue: setCustomersInputValue,
loadMore: loadMoreCustomers,
} = useSelect(CustomerApi.basePath, 'id', 'name', 'search', {
has_marketing: 'true',
} = useSelect(MarketingApi.basePath, 'customer.id', 'customer.name', '', {
limit: 'limit',
});
const {
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
setInputValue: setProjectFlockInputValue,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search'
);
const uniqueCustomersOptions = useMemo(() => {
const seen = new Set();
return customersOptions.filter((customer) => {
if (seen.has(customer.value)) return false;
seen.add(customer.value);
return true;
});
}, [customersOptions]);
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
@@ -87,30 +87,23 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' },
];
const formik = useFormik<MarketingFilterFormValues>({
initialValues: initialValues || {
const formik = useFormik<{
product_ids: OptionType[];
status: OptionType | null;
customer_id: OptionType | null;
}>({
initialValues: {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
customer_id: null,
},
validationSchema: MarketingFilterSchema,
onSubmit: async (values) => {
const formattedValues: MarketingFilter = {
const formattedValues = {
...values,
product_ids: values.product_ids.map((item) => Number(item.value)),
product_names: values.product_ids.map((item) => item.label),
status: values.status?.value.toString() || '',
status_name: values.status?.label || '-',
customer_id: Number(values.customer?.value),
customer_name: values.customer?.label || '-',
project_flock_id: values.project_flock?.value || undefined,
project_flock_name: values.project_flock?.label,
project_flock_kandang_id:
Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name:
values.project_flock_kandang?.label || undefined,
customer_id: Number(values.customer_id?.value),
};
onSubmit?.(formattedValues);
@@ -123,58 +116,18 @@ const MarketingFilterModal = ({
},
});
const { resetForm } = formik;
const formikResetHandler = useCallback(() => {
resetForm({
values: {
product_ids: [],
status: null,
customer: null,
project_flock: null,
project_flock_kandang: null,
},
});
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
const customerChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue(
'customer',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
formik.setFieldValue('customer_id', val as OptionType);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val as OptionType);
};
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
return (
<Modal
ref={ref}
@@ -184,7 +137,7 @@ const MarketingFilterModal = ({
>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
onReset={formik.handleReset}
className='w-full flex flex-col'
>
{/* Modal Header */}
@@ -234,44 +187,13 @@ const MarketingFilterModal = ({
label='Customer'
isClearable
placeholder='Pilih customer'
options={customersOptions}
options={uniqueCustomersOptions}
isLoading={isLoadingCustomersOptions}
value={formik.values.customer}
value={formik.values.customer_id}
onChange={customerChangeHandler}
onInputChange={setCustomersInputValue}
onMenuScrollToBottom={loadMoreCustomers}
/>
<SelectInput
label='Project Flock'
isClearable
placeholder='Pilih Project Flock'
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
value={formik.values.project_flock}
onChange={(val) => {
formik.setFieldValue(
'project_flock',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
);
formik.setFieldValue('project_flock_kandang', null);
}}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
/>
<SelectInput
label='Kandang'
isClearable
placeholder='Pilih Kandang'
options={projectFlockKandangOptions}
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
)
}
isDisabled={!formik.values.project_flock}
/>
</div>
{/* Modal Footer */}
+62 -546
View File
@@ -2,39 +2,26 @@
import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import Modal, { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import {
getErrorMessage,
isResponseError,
isResponseSuccess,
} from '@/lib/api-helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatCurrency, formatDate, formatTitleCase } from '@/lib/helper';
import {
MarketingApi,
SalesOrderApi,
} from '@/services/api/marketing/marketing';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { BaseApiResponse } from '@/types/api/api-general';
import {
BaseSalesOrder,
Marketing,
MarketingFilter,
} from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react';
import {
CellContext,
ColumnDef,
Row,
SortingState,
Updater,
} from '@tanstack/react-table';
import { CellContext, ColumnDef, Row } from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import RequirePermission from '@/components/helper/RequirePermission';
@@ -167,21 +154,12 @@ const MarketingTable = () => {
);
const [selectedItem, setSelectedItem] = useState<Marketing | null>(null);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [bulkDeliveryDate, setBulkDeliveryDate] = useState('');
const [bulkDeliveryNotes, setBulkDeliveryNotes] = useState('');
const [isSubmittingBulkDelivery, setIsSubmittingBulkDelivery] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const router = useRouter();
const deleteModal = useModal();
const confirmationModal = useModal();
const productsModal = useModal();
const deliveryModal = useModal();
const bulkDeliveryModal = useModal();
const exportProgressInputModal = useModal();
const filterModal = useModal();
const {
@@ -194,17 +172,8 @@ const MarketingTable = () => {
initial: {
search: '',
product_ids: '',
product_names: '',
status: '',
status_name: '',
customer_id: '',
customer_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
sort_by: '',
order_by: '',
},
paramMap: {
page: 'page',
@@ -212,43 +181,9 @@ const MarketingTable = () => {
product_ids: 'product_ids',
status: 'status',
customer_id: 'customer_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
sort_by: 'sort_by',
order_by: 'sort_order',
},
excludeKeysFromUrl: [
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'marketing-table',
});
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== FETCH DATA =====
const {
data: marketing,
@@ -263,35 +198,12 @@ const MarketingTable = () => {
const filterSubmitHandler = (values: MarketingFilter) => {
updateFilter(
'product_ids',
values.product_ids?.map((item) => item.toString()).join(','),
true
values.product_ids?.map((item) => item.toString()).join(',')
);
updateFilter('product_names', values.product_names?.join(','));
updateFilter('status', values.status ? values.status.toString() : '', true);
updateFilter('status_name', values.status_name, true);
updateFilter('status', values.status ? values.status.toString() : '');
updateFilter(
'customer_id',
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
values.customer_id ? values.customer_id.toString() : ''
);
};
@@ -299,26 +211,13 @@ const MarketingTable = () => {
useState(false);
const filterResetHandler = () => {
updateFilter('product_ids', '', true);
updateFilter('product_names', '', true);
updateFilter('status', '', true);
updateFilter('status_name', '', true);
updateFilter('customer_id', '', true);
updateFilter('customer_name', '', true);
updateFilter('project_flock_id', '', true);
updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', true);
updateFilter('product_ids', '');
updateFilter('status', '');
updateFilter('customer_id', '');
};
const approveClickHandler = () => {
setApproveAction('APPROVED');
if (selectedApprovalStep === 2) {
bulkDeliveryModal.openModal();
return;
}
confirmationModal.openModal();
};
@@ -327,13 +226,10 @@ const MarketingTable = () => {
confirmationModal.openModal();
};
const productsClickHandler = useCallback(
(item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
},
[productsModal]
);
const productsClickHandler = (item: Marketing) => {
setSelectedItem(item);
productsModal.openModal();
};
const deleteMarketingHandler = async () => {
const deleteMarketingRes = await MarketingApi.delete(
@@ -355,180 +251,61 @@ const MarketingTable = () => {
const selectedRowsData = allData.filter(
(row) => rowSelection[row.id.toString()]
);
const selectedApprovalStep =
selectedRowsData.length > 0
? selectedRowsData[0].latest_approval.step_number
: null;
const eligibleSelectedRows = selectedRowsData.filter((row) => {
const approval = row.latest_approval;
if (approval.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return approval.step_number === 1 || approval.step_number === 2;
}
return approval.step_number === selectedApprovalStep;
});
const hasApprovable = eligibleSelectedRows.length > 0;
const hasRejectable = eligibleSelectedRows.length > 0;
const hasApprovable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const hasRejectable = selectedRowsData.some(
(row) =>
row.latest_approval.step_number === 1 &&
row.latest_approval.action !== 'REJECTED'
);
const disableApprove = !hasApprovable;
const disableReject = !hasRejectable;
const idsToProcess = eligibleSelectedRows.map((row) => row.id);
const nextApprovalStatus =
selectedApprovalStep === 1
? 'SALES_ORDER'
: selectedApprovalStep === 2
? 'DELIVERY_ORDER'
: null;
const productIds = tableFilterState.product_ids
? tableFilterState.product_ids
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const productLabels = tableFilterState.product_names
? tableFilterState.product_names
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const marketingFilterInitialValues = {
product_ids: productIds.map((value, idx) => ({
value: Number(value),
label: productLabels[idx] || '-',
})),
status: tableFilterState.status
? {
value: tableFilterState.status,
label: tableFilterState.status_name,
}
: null,
customer: tableFilterState.customer_id
? {
value: Number(tableFilterState.customer_id),
label: tableFilterState.customer_name,
}
: null,
project_flock: tableFilterState.project_flock_id
? {
value: Number(tableFilterState.project_flock_id),
label: tableFilterState.project_flock_name,
}
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? {
value: Number(tableFilterState.project_flock_kandang_id),
label: tableFilterState.project_flock_kandang_name,
}
: null,
};
const idsToProcess =
approveAction === 'APPROVED'
? selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id)
: selectedRowsData
.filter((row) => row.latest_approval.step_number === 2)
.map((row) => row.id);
const approveMarketingHandler = async (notes: string) => {
let idsToProcess: number[] = [];
idsToProcess = selectedRowsData
.filter((row) => row.latest_approval.step_number === 1)
.map((row) => row.id);
if (idsToProcess.length === 0) {
toast.error(`Tidak ada data yang valid untuk di ${approveAction}.`);
confirmationModal.closeModal();
return;
}
if (approveAction === 'APPROVED' && selectedApprovalStep !== 1) {
toast.error('Approve tahap ini harus menggunakan tanggal pengiriman.');
confirmationModal.closeModal();
return;
}
if (approveAction === 'APPROVED' && !nextApprovalStatus) {
toast.error('Status approval berikutnya tidak valid.');
confirmationModal.closeModal();
return;
}
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);
const approveMarketingRes = await SalesOrderApi.bulkApprovals(
idsToProcess,
approveAction,
notes
);
if (isResponseSuccess(approveMarketingRes)) {
confirmationModal.closeModal();
toast.success(approveMarketingRes?.message as string);
setRowSelection({});
}
if (isResponseError(approveMarketingRes)) {
confirmationModal.closeModal();
toast.error(approveMarketingRes?.message as string);
}
refreshMarketing();
};
const bulkDeliveryDateChangeHandler: ChangeEventHandler<HTMLInputElement> = (
e
) => {
setBulkDeliveryDate(e.target.value);
};
const bulkDeliveryNotesChangeHandler: ChangeEventHandler<
HTMLTextAreaElement
> = (e) => {
setBulkDeliveryNotes(e.target.value);
};
const submitBulkDeliveryApprovalHandler = async (
selectedIds: number[],
deliveryDate: string,
notes: string
) => {
if (selectedIds.length === 0) {
toast.error('Tidak ada data yang valid untuk diproses.');
return;
}
if (!deliveryDate) {
toast.error('Tanggal pengiriman wajib diisi.');
return;
}
setIsSubmittingBulkDelivery(true);
try {
const bulkDeliveryApprovalRes = await MarketingApi.bulkApprovals(
selectedIds,
'DELIVERY_ORDER',
deliveryDate,
notes || `APPROVED delivery marketing ${selectedIds.join(', ')}`
);
if (isResponseError(bulkDeliveryApprovalRes)) {
toast.error(bulkDeliveryApprovalRes?.message as string);
return;
}
if (!isResponseSuccess(bulkDeliveryApprovalRes)) {
toast.error('Gagal memproses bulk approve delivery.');
return;
}
toast.success(bulkDeliveryApprovalRes?.message as string);
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
setRowSelection({});
refreshMarketing();
} finally {
setIsSubmittingBulkDelivery(false);
}
};
const confirmationModalDeliveryClickHandler = async (notes: string) => {
const res = await SalesOrderApi.delivery(selectedItem?.id as number, notes);
deliveryModal.closeModal();
@@ -539,24 +316,10 @@ const MarketingTable = () => {
);
};
const getRowCanSelect = useCallback(
(row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
const isSelectableStep =
approval?.step_number === 1 || approval?.step_number === 2;
if (!isSelectableStep || approval?.action === 'REJECTED') {
return false;
}
if (selectedApprovalStep === null) {
return true;
}
return approval?.step_number === selectedApprovalStep;
},
[selectedApprovalStep]
);
const getRowCanSelect = (row: Row<Marketing>): boolean => {
const approval = row.original.latest_approval;
return approval?.step_number === 1 && approval?.action !== 'REJECTED';
};
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
@@ -566,53 +329,6 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = () => {
setExportProgressStartDate('');
setExportProgressEndDate('');
};
const exportProgressStartDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressStartDate(e.target.value);
};
const exportProgressEndDateChangeHandler: ChangeEventHandler<
HTMLInputElement
> = (e) => {
setExportProgressEndDate(e.target.value);
};
const exportProgressInputToExcelClickHandler = () => {
resetExportProgressForm();
exportProgressInputModal.openModal();
};
const submitExportProgressInputHandler = async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await MarketingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
};
const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [
{
@@ -620,22 +336,7 @@ const MarketingTable = () => {
size: 1,
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const stepForBulkSelection =
selectedApprovalStep ??
allRows.find(getRowCanSelect)?.original.latest_approval.step_number;
const selectableRows = allRows.filter((row) => {
if (!getRowCanSelect(row)) {
return false;
}
if (!stepForBulkSelection) {
return false;
}
return (
row.original.latest_approval.step_number === stepForBulkSelection
);
});
const selectableRows = allRows.filter(getRowCanSelect);
const allSelected =
selectableRows.length > 0 &&
@@ -677,7 +378,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'so_number',
accessorKey: 'so_do_number',
header: 'No. Order',
cell: (props) => {
return props.row.original.do_number
@@ -693,7 +394,7 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'status',
accessorKey: 'approval.step_name',
header: 'Status',
cell: (props) => {
const approval = props.row.original.latest_approval;
@@ -728,12 +429,10 @@ const MarketingTable = () => {
},
},
{
accessorKey: 'customer',
accessorKey: 'customer.name',
header: 'Customer',
cell: (props) => props.row.original.customer.name,
},
{
accessorKey: 'grand_total',
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
@@ -750,7 +449,6 @@ const MarketingTable = () => {
{
accessorKey: 'marketing_products.length',
header: 'Product Details',
enableSorting: false,
cell: (props) => {
if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) {
@@ -806,13 +504,7 @@ const MarketingTable = () => {
},
},
];
}, [
deleteModal,
deliveryModal,
getRowCanSelect,
productsClickHandler,
selectedApprovalStep,
]);
}, []);
return (
<>
@@ -835,7 +527,7 @@ const MarketingTable = () => {
</RequirePermission>
{idsToProcess.length > 0 && (
<>
<div className='divider divider-horizontal w-px p-0 m-0 bg-base-content/10 text-base-content/10 before:bg-base-content/10 before:w-px after:bg-base-content/10 after:w-px' />
<div 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'>
<Button
color='error'
@@ -849,7 +541,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Reject ({idsToProcess.length} Item)
Reject
</Button>
</RequirePermission>
<RequirePermission permissions='lti.marketing.sales_order.approve'>
@@ -865,7 +557,7 @@ const MarketingTable = () => {
width={20}
height={20}
/>
Approve ({idsToProcess.length} Item)
Approve
</Button>
</RequirePermission>
</>
@@ -874,18 +566,7 @@ const MarketingTable = () => {
<div className='flex flex-row gap-3'>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'product_names',
'status_name',
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'sort_by',
'order_by',
]}
excludeFields={['page', 'pageSize', 'search']}
onClick={() => {
filterModal.openModal();
}}
@@ -931,17 +612,7 @@ const MarketingTable = () => {
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
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)
Export to Excel
</Button>
</Dropdown>
</div>
@@ -975,9 +646,6 @@ const MarketingTable = () => {
columns={columns}
pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
manualSorting
totalItems={
isResponseSuccess(marketing)
? marketing?.meta?.total_results
@@ -1009,7 +677,7 @@ const MarketingTable = () => {
<ConfirmationModalWithNotes
ref={confirmationModal.ref}
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={{
text: 'Tidak',
onClick: confirmationModal.closeModal,
@@ -1048,157 +716,6 @@ const MarketingTable = () => {
}}
/>
<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'
onClick={() => {
bulkDeliveryModal.closeModal();
setBulkDeliveryDate('');
setBulkDeliveryNotes('');
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
isLoading={isSubmittingBulkDelivery}
onClick={() =>
submitBulkDeliveryApprovalHandler(
idsToProcess,
bulkDeliveryDate,
bulkDeliveryNotes
)
}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
<Modal
ref={productsModal.ref}
className={{
@@ -1229,7 +746,7 @@ const MarketingTable = () => {
}
columns={[
{
header: 'Gudang Fisik',
header: 'Kandang',
accessorFn(row) {
return row.product_warehouse.warehouse.name;
},
@@ -1260,7 +777,6 @@ const MarketingTable = () => {
ref={filterModal.ref}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
initialValues={marketingFilterInitialValues}
/>
</>
);
@@ -195,9 +195,7 @@ const SalesOrderFormModal = ({
product.marketing_type?.value?.toLowerCase() === 'telur'
? convertionUnitValue === 'PETI'
? 'PETI'
: convertionUnitValue === 'QTY'
? 'QTY'
: 'KG'
: 'KG' // termasuk "QTY" dan "KG"
: undefined;
// Jika value dari data product ada week, kirim "AYAM_PULLET, jika tidak ada kirim "AYAM"
@@ -209,6 +207,7 @@ const SalesOrderFormModal = ({
return {
vehicle_number: product.vehicle_number as string,
kandang_id: product.kandang_id as number,
product_warehouse_id: product.product_warehouse_id as number,
unit_price: parseFloat(String(product.unit_price || 0)),
total_weight: parseFloat(String(product.total_weight || 0)),
@@ -1,18 +0,0 @@
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
product_ids: array().of(mixed<OptionType<number>>().required()).required(),
status: mixed<OptionType<string>>().nullable(),
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
});
export type MarketingFilterFormValues = {
product_ids: OptionType<number>[];
status: OptionType<string> | null;
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
@@ -13,7 +13,6 @@ import {
Marketing,
} from '@/types/api/marketing/marketing';
import { formatDate, formatTitleCase } from '@/lib/helper';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type MarketingSchemaType = {
customer_id: number | undefined;
@@ -98,21 +97,17 @@ export type DeliveryOrderFormValues = Yup.InferType<typeof DeliveryOrderSchema>;
export const SalesProductToFieldValues = (
product: BaseSalesOrder
): SalesOrderProductFormValues => {
const warehouseOption = {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
};
return {
id: product.id,
vehicle_number: product.vehicle_number,
warehouse_id: product.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: product.product_warehouse.warehouse.id,
kandang: warehouseOption,
kandang: {
value: product.product_warehouse.warehouse.id,
label: product.product_warehouse.warehouse.name,
},
product_warehouse: {
value: product.product_warehouse.id,
label: getProductWarehouseOptionLabel(product.product_warehouse),
label: product.product_warehouse.product.name,
},
product_warehouse_data: product.product_warehouse,
product_warehouse_id: product.product_warehouse.id,
@@ -144,36 +139,11 @@ export const DeliveryProductToFieldValues = (
delivery: BaseDeliveryOrder
): DeliveryOrderProductFormValues[] => {
const data = delivery.deliveries.map((item) => {
const salesOrder =
salesOrders.find((so) => so.id === item.marketing_product_id) ??
salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
);
const warehouseOption = {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
};
const initialSisaBerat =
item?.total_weight &&
salesOrder?.weight_per_convertion &&
salesOrder?.total_peti
? Number(item.total_weight) -
Number(salesOrder.weight_per_convertion) *
Number(salesOrder.total_peti)
: 0;
const initialPricePerConvertion =
item?.total_price &&
salesOrder?.total_peti &&
Number(salesOrder.total_peti) !== 0
? (Number(item.total_price) -
initialSisaBerat * Number(item.unit_price || 0)) /
Number(salesOrder.total_peti)
: Number(item?.unit_price || 0);
const soId = salesOrders.find(
(so) => so.product_warehouse.id === item.product_warehouse.id
)?.id;
return {
id: salesOrder?.id,
id: soId,
unit_price: item.unit_price,
total_weight: item.total_weight,
qty: item.qty,
@@ -182,31 +152,19 @@ export const DeliveryProductToFieldValues = (
vehicle_number: item.vehicle_number,
delivery_date: formatDate(delivery.delivery_date, 'yyyy-MM-DD'),
do_number: delivery.do_number,
marketing_product_id: item.marketing_product_id ?? salesOrder?.id,
marketing_type: salesOrder?.marketing_type
? {
value: salesOrder?.marketing_type,
label: formatTitleCase(salesOrder?.marketing_type),
}
: null,
convertion_unit: salesOrder?.convertion_unit
? {
value: salesOrder?.convertion_unit.toLowerCase(),
label: formatTitleCase(salesOrder?.convertion_unit),
}
: null,
marketing_product_id: soId,
marketing_product: {
id: item.marketing_product_id ?? salesOrder?.id,
id: soId,
vehicle_number: item.vehicle_number,
warehouse_id: item.product_warehouse.warehouse.id,
warehouse: warehouseOption,
kandang_id: item.product_warehouse.warehouse.id,
kandang: warehouseOption,
kandang: {
value: item.product_warehouse.warehouse.id,
label: item.product_warehouse.warehouse.name,
},
product_warehouse: {
value: item.product_warehouse.id,
label: getProductWarehouseOptionLabel(item.product_warehouse),
label: item.product_warehouse.product.name,
},
product_warehouse_data: item.product_warehouse,
product_warehouse_id: item.product_warehouse.id,
unit_price: item.unit_price,
total_weight: item.total_weight,
@@ -214,13 +172,8 @@ export const DeliveryProductToFieldValues = (
avg_weight: item.avg_weight,
total_price: item.total_price,
},
total_peti: salesOrder?.total_peti,
weight_per_convertion:
item?.weight_per_convertion ?? salesOrder?.weight_per_convertion ?? 0,
price_per_convertion: initialPricePerConvertion,
} as DeliveryOrderProductFormValues;
});
return data;
};
export const mergeSOwithDO = (
@@ -228,25 +181,10 @@ export const mergeSOwithDO = (
deliveryOrders: DeliveryOrderProductFormValues[],
autofill?: boolean
): DeliveryOrderProductFormValues[] => {
const hasDeliveryOrders = deliveryOrders.length > 0;
return salesOrders.map((so) => {
const delivery = deliveryOrders.find(
(d) => d?.marketing_product_id === so.id
);
const isTelurQty =
so.marketing_type?.value?.toLowerCase() === 'telur' &&
so.convertion_unit?.value?.toLowerCase() === 'qty';
const salesOrderUnitPrice =
isTelurQty && Number(so.total_price || 0) > 0 && Number(so.qty || 0) > 0
? Number(so.total_price) / Number(so.qty)
: so.unit_price;
const salesOrderPricePerQty =
isTelurQty &&
Number(so.total_price || 0) > 0 &&
Number(so.total_weight || 0) > 0
? Number(so.total_price) / Number(so.total_weight)
: so.price_per_qty;
return {
...so, // nilai dasar dari sales order
@@ -254,50 +192,30 @@ export const mergeSOwithDO = (
delivery_date: delivery?.delivery_date || undefined,
do_number: delivery?.do_number || undefined,
vehicle_number: delivery?.vehicle_number || so.vehicle_number,
unit_price:
autofill && hasDeliveryOrders
? delivery?.unit_price
: salesOrderUnitPrice,
total_weight:
autofill && hasDeliveryOrders
? delivery?.total_weight
: so.total_weight,
qty: autofill && hasDeliveryOrders ? delivery?.qty : so.qty,
avg_weight:
autofill && hasDeliveryOrders ? delivery?.avg_weight : so.avg_weight,
total_price:
autofill && hasDeliveryOrders ? delivery?.total_price : so.total_price,
unit_price: autofill ? so.unit_price : delivery?.unit_price,
total_weight: autofill ? so.total_weight : delivery?.total_weight,
qty: autofill ? so.qty : delivery?.qty,
avg_weight: autofill ? so.avg_weight : delivery?.avg_weight,
total_price: autofill ? so.total_price : delivery?.total_price,
marketing_product: so, // jika ada, override
uom: autofill && hasDeliveryOrders ? delivery?.uom : so.uom,
weight_per_convertion:
autofill && hasDeliveryOrders
? delivery?.weight_per_convertion
: so.weight_per_convertion,
price_per_convertion:
autofill && hasDeliveryOrders
? delivery?.price_per_convertion
: so.price_per_convertion,
convertion_unit:
autofill && hasDeliveryOrders
? delivery?.convertion_unit
: so.convertion_unit,
marketing_type:
autofill && hasDeliveryOrders
? delivery?.marketing_type
: so.marketing_type,
total_peti:
autofill && hasDeliveryOrders ? delivery?.total_peti : so.total_peti,
price_per_qty:
autofill && hasDeliveryOrders
? delivery?.price_per_qty
: salesOrderPricePerQty,
sisa_berat:
autofill && hasDeliveryOrders ? delivery?.sisa_berat : so.sisa_berat,
price_sisa_berat:
autofill && hasDeliveryOrders
? delivery?.price_sisa_berat
: so.price_sisa_berat,
week: autofill && hasDeliveryOrders ? delivery?.week : so.week,
uom: autofill ? so.uom : delivery?.uom,
weight_per_convertion: autofill
? so.weight_per_convertion
: delivery?.weight_per_convertion,
price_per_convertion: autofill
? so.price_per_convertion
: delivery?.price_per_convertion,
convertion_unit: autofill
? so.convertion_unit
: delivery?.convertion_unit,
marketing_type: autofill ? so.marketing_type : delivery?.marketing_type,
total_peti: autofill ? so.total_peti : delivery?.total_peti,
price_per_qty: autofill ? so.price_per_qty : delivery?.price_per_qty,
sisa_berat: autofill ? so.sisa_berat : delivery?.sisa_berat,
price_sisa_berat: autofill
? so.price_sisa_berat
: delivery?.price_sisa_berat,
week: autofill ? so.week : delivery?.week,
} as DeliveryOrderProductFormValues;
});
};
@@ -32,63 +32,6 @@ import Dropdown from '@/components/Dropdown';
import { Icon } from '@iconify/react';
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
type PricingOption =
| string
| {
value: string;
label: string;
}
| null
| undefined;
type PricingSource =
| {
marketing_type?: PricingOption;
convertion_unit?: PricingOption;
total_price?: string | number | null;
qty?: string | number | null;
total_weight?: string | number | null;
unit_price?: string | number | null;
price_per_qty?: number | null;
}
| null
| undefined;
const getOptionValue = (value?: PricingOption) => {
if (!value) return undefined;
if (typeof value === 'string') return value.toLowerCase();
return value.value?.toLowerCase();
};
const isTelurQtyProduct = (value?: PricingSource) =>
getOptionValue(value?.marketing_type) === 'telur' &&
getOptionValue(value?.convertion_unit) === 'qty';
const getDisplayedUnitPrice = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.qty || 0) > 0
) {
return Number(value?.total_price) / Number(value?.qty);
}
return value?.unit_price ?? undefined;
};
const getDisplayedPricePerQty = (value?: PricingSource) => {
if (
isTelurQtyProduct(value) &&
Number(value?.total_price || 0) > 0 &&
Number(value?.total_weight || 0) > 0
) {
return Number(value?.total_price) / Number(value?.total_weight);
}
return value?.price_per_qty ?? null;
};
const DeliveryOrderProductForm = ({
formState,
salesOrders,
@@ -133,7 +76,7 @@ const DeliveryOrderProductForm = ({
? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti)
: Number(initialValues?.unit_price || 0);
: 0;
const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti
@@ -169,7 +112,7 @@ const DeliveryOrderProductForm = ({
if (!Boolean(item.qty)) {
return {
value: item.id,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.warehouse?.label ?? item.marketing_product?.kandang?.label}`,
label: `${item.marketing_product?.product_warehouse?.label} - ${item.marketing_product?.kandang?.label}`,
} as OptionType;
} else {
return null;
@@ -190,19 +133,12 @@ const DeliveryOrderProductForm = ({
const deliveryOrder = useMemo(() => {
if (!hasDeliveryOrder || !deliveryOrders) return null;
const marketingProductId =
initialValues?.marketing_product_id ?? initialValues?.id;
for (const doItem of deliveryOrders) {
const found =
doItem.deliveries.find(
(d) => d.marketing_product_id === marketingProductId
) ??
doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
const found = doItem.deliveries.find(
(d) =>
d.product_warehouse.id ===
initialValues?.marketing_product?.product_warehouse_id
);
if (found) {
return {
...found,
@@ -218,27 +154,6 @@ const DeliveryOrderProductForm = ({
(item) => item.id === initialValues?.marketing_product_id
);
const defaultPricingSource: PricingSource = {
marketing_type:
initialValues?.marketing_type ?? salesOrder?.marketing_type ?? null,
convertion_unit:
initialValues?.convertion_unit ?? salesOrder?.convertion_unit ?? null,
total_price:
deliveryOrder?.total_price ??
initialValues?.total_price ??
salesOrder?.total_price,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? salesOrder?.qty,
total_weight:
deliveryOrder?.total_weight ??
initialValues?.total_weight ??
salesOrder?.total_weight,
unit_price:
deliveryOrder?.unit_price ??
initialValues?.unit_price ??
salesOrder?.unit_price,
price_per_qty: initialValues?.price_per_qty ?? null,
};
const formik = useFormik<DeliveryOrderProductFormValues>({
enableReinitialize: true,
initialValues: {
@@ -252,7 +167,8 @@ const DeliveryOrderProductForm = ({
undefined,
marketing_product_id:
salesOrder?.id || initialValues?.marketing_product_id || undefined,
unit_price: getDisplayedUnitPrice(defaultPricingSource),
unit_price:
deliveryOrder?.unit_price ?? initialValues?.unit_price ?? undefined,
total_weight:
deliveryOrder?.total_weight ?? initialValues?.total_weight ?? undefined,
qty: deliveryOrder?.qty ?? initialValues?.qty ?? undefined,
@@ -270,7 +186,7 @@ const DeliveryOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null,
price_per_qty: getDisplayedPricePerQty(defaultPricingSource),
price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null,
@@ -410,21 +326,14 @@ const DeliveryOrderProductForm = ({
useEffect(() => {
if (initialValues) {
if (
!Boolean(initialValues.qty) &&
!Boolean(initialValues.marketing_product_id)
) {
if (!Boolean(initialValues.qty)) {
handleResetForm();
} else {
setFormikValues({
...initialValues,
unit_price: getDisplayedUnitPrice(initialValues),
price_per_qty: getDisplayedPricePerQty(initialValues),
});
setFormikValues(initialValues);
if (initialValues?.marketing_product_id) {
setSelectedProduct({
value: initialValues?.marketing_product_id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.warehouse?.label ?? initialValues?.marketing_product?.kandang?.label}`,
value: initialValues?.id,
label: `${initialValues?.marketing_product?.product_warehouse?.label} - ${initialValues?.marketing_product?.kandang?.label}`,
} as OptionType);
}
}
@@ -549,11 +458,10 @@ const DeliveryOrderProductForm = ({
marketing_product_id: selected.value as number,
marketing_product: soFieldValues,
qty: so.qty,
unit_price: getDisplayedUnitPrice(so),
unit_price: so.unit_price,
total_price: so.total_price,
avg_weight: so.avg_weight,
total_weight: so.total_weight,
price_per_qty: getDisplayedPricePerQty(so),
vehicle_number: so.vehicle_number,
week: soFieldValues.week ?? null,
});
@@ -564,11 +472,7 @@ const DeliveryOrderProductForm = ({
text={
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.warehouse?.label ??
exisitingValues?.find(
(item) => item.id === selectedProduct?.value
)?.marketing_product?.kandang?.label ??
''
)?.marketing_product?.kandang?.label ?? ''
}
color='success'
className={{
@@ -734,7 +638,7 @@ const DeliveryOrderProductForm = ({
placeholder='Masukan Total Peti'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Peti</span>
<span className='text-sm text-base-content/50'>Kg</span>
</div>
}
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -784,9 +688,6 @@ const DeliveryOrderProductForm = ({
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -856,32 +757,12 @@ const DeliveryOrderProductForm = ({
/>
)}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{/* Harga per butir untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Kg (Rp)'
label='Harga / Butir (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
@@ -895,7 +776,27 @@ const DeliveryOrderProductForm = ({
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg'
placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${isResponseSuccess(productData) ? productData?.data?.uom?.name : 'Produk'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={Boolean(formik.errors.unit_price)}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
@@ -3,11 +3,6 @@ import * as Yup from 'yup';
type SalesOrderProductSchemaType = {
id?: number | undefined;
warehouse_id?: number;
warehouse?: {
value: number;
label: string;
} | null;
kandang_id?: number;
kandang?: {
value: number;
@@ -49,22 +44,15 @@ export const SalesOrderProductSchema: Yup.ObjectSchema<SalesOrderProductSchemaTy
Yup.object({
id: Yup.number(),
vehicle_number: Yup.string().required('Nomor Kendaraan wajib diisi!'),
warehouse: Yup.object({
value: Yup.number()
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
label: Yup.string().required('Gudang fisik wajib diisi!'),
}).nullable(),
warehouse_id: Yup.number()
.min(1, 'Gudang fisik wajib diisi!')
.required('Gudang fisik wajib diisi!'),
kandang: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
kandang_id: Yup.number().optional(),
value: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
label: Yup.string().required('Kandang wajib diisi!'),
}).nullable(),
kandang_id: Yup.number()
.min(1, 'Kandang wajib diisi!')
.required('Kandang wajib diisi!'),
product_warehouse: Yup.object({
value: Yup.number()
.min(1, 'Produk wajib diisi!')
@@ -7,7 +7,7 @@ import {
} from '@/components/pages/marketing/form/repeater/sales-order/SalesOrderProduct.schema';
import { RefObject, useEffect, useMemo, useState } from 'react';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { Kandang } from '@/types/api/master-data/kandang';
import { WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouse } from '@/types/api/inventory/product-warehouse';
import { ProductWarehouseApi } from '@/services/api/inventory';
@@ -31,7 +31,6 @@ import {
import { Icon } from '@iconify/react';
import Dropdown from '@/components/Dropdown';
import { handleMarketingCalculation } from '@/lib/marketing-calculation';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
const SalesOrderProductForm = ({
initialValues,
@@ -68,25 +67,7 @@ const SalesOrderProductForm = ({
? (Number(initialValues.total_price) -
initialSisaBerat * Number(initialValues.unit_price || 0)) /
Number(initialValues.total_peti)
: Number(initialValues?.unit_price || 0);
const isInitialTelurQty =
initialValues?.marketing_type?.value?.toLowerCase() === 'telur' &&
initialValues?.convertion_unit?.value?.toLowerCase() === 'qty';
const initialUnitPrice =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.qty || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.qty)
: initialValues?.unit_price || '';
const initialPricePerQty =
isInitialTelurQty &&
Number(initialValues?.total_price || 0) > 0 &&
Number(initialValues?.total_weight || 0) > 0
? Number(initialValues?.total_price) / Number(initialValues?.total_weight)
: (initialValues?.price_per_qty ?? null);
: 0;
const initialPriceSisaBerat =
initialValues?.total_price && initialValues?.total_peti
@@ -103,15 +84,11 @@ const SalesOrderProductForm = ({
enableReinitialize: true,
initialValues: {
vehicle_number: initialValues?.vehicle_number || '',
warehouse_id:
initialValues?.warehouse_id ?? initialValues?.kandang_id ?? undefined,
warehouse: initialValues?.warehouse ?? initialValues?.kandang ?? null,
kandang_id: initialValues?.kandang_id || undefined,
kandang: initialValues?.kandang || null,
product_warehouse: initialValues?.product_warehouse || null,
product_warehouse_data: initialValues?.product_warehouse_data || null,
product_warehouse_id: initialValues?.product_warehouse_id || undefined,
unit_price: initialUnitPrice,
unit_price: initialValues?.unit_price || '',
total_weight: initialValues?.total_weight || '',
qty: initialValues?.qty || '',
avg_weight: initialValues?.avg_weight || '',
@@ -125,7 +102,7 @@ const SalesOrderProductForm = ({
convertion_unit: initialValues?.convertion_unit || null,
marketing_type: initialValues?.marketing_type || null,
total_peti: initialValues?.total_peti ?? null,
price_per_qty: initialPricePerQty,
price_per_qty: initialValues?.price_per_qty ?? null,
sisa_berat: initialSisaBerat,
price_sisa_berat: initialPriceSisaBerat,
week: initialValues?.week ?? null,
@@ -155,11 +132,11 @@ const SalesOrderProductForm = ({
// ===== Options =====
const {
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setWarehouseSearchValue,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name');
options: kandangSourceOptions,
isLoadingOptions: isLoadingKandangSourceOptions,
setInputValue: setKandangInputValue,
loadMore: loadMoreKandang,
} = useSelect<Kandang>(WarehouseApi.basePath, 'id', 'name');
// Options Week dari minggu 1 - 22
// const optionsWeek = useMemo(() => {
@@ -170,6 +147,7 @@ const SalesOrderProductForm = ({
// }, []);
const {
options: warehouseSourceOptions,
rawData: warehouseSourceRawData,
isLoadingOptions: isLoadingWarehouseSourceOptions,
setInputValue: setWarehouseInputValue,
@@ -178,69 +156,32 @@ const SalesOrderProductForm = ({
ProductWarehouseApi.basePath,
'id',
'product.name',
'search',
'',
{
limit: '100',
available_only: 'true',
warehouse_id: formik.values.warehouse_id?.toString() ?? '',
warehouse_id: formik.values.kandang_id?.toString() ?? '',
type: formik.values.marketing_type?.value.toLocaleUpperCase() ?? '',
}
);
const productOptionsFiltered = useMemo(() => {
if (!isResponseSuccess(warehouseSourceRawData)) {
return initialValues?.product_warehouse
? [initialValues.product_warehouse]
: [];
}
const selectedProductIds = new Set(
exisitingValues
?.filter((item) => item.id !== initialValues?.id)
.map((item) => Number(item.product_warehouse_id))
.filter((item) => item > 0) ?? []
return warehouseSourceOptions.filter(
(product) =>
!exisitingValues
?.map((item) => item.product_warehouse_id)
.includes(product.value)
);
const options = warehouseSourceRawData.data
.filter((item: ProductWarehouse) => !selectedProductIds.has(item.id))
.map((item: ProductWarehouse) => ({
value: item.id,
label: getProductWarehouseOptionLabel(item),
}));
if (
initialValues?.product_warehouse &&
initialValues?.product_warehouse_id
) {
const exists = options.find(
(option) =>
Number(option.value) === Number(initialValues.product_warehouse_id)
);
if (!exists) {
options.push(initialValues.product_warehouse);
}
}
return options;
}, [warehouseSourceRawData, exisitingValues, initialValues]);
}, [warehouseSourceOptions, exisitingValues]);
// ===== Handler =====
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
const warehouse = (val as OptionType | null) ?? null;
formik.setFieldValue('warehouse', warehouse);
formik.setFieldValue('warehouse_id', warehouse?.value);
formik.setFieldValue('kandang', warehouse);
formik.setFieldValue('kandang_id', warehouse?.value);
const kandangChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang', val as OptionType);
formik.setFieldValue('kandang_id', (val as OptionType)?.value);
formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', '');
};
const productWarehouseChangeHandler = (
val: OptionType | OptionType[] | null
) => {
const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_warehouse', val as OptionType);
const newId = (val as OptionType)?.value;
formik.setFieldValue('product_warehouse_id', newId);
@@ -250,7 +191,6 @@ const SalesOrderProductForm = ({
(item: ProductWarehouse) => item.id === newId
);
setSelectedProductWarehouse(productWarehouse || null);
formik.setFieldValue('product_warehouse_data', productWarehouse || null);
formik.setFieldValue('qty', productWarehouse?.quantity);
formik.setFieldValue('uom', productWarehouse?.product?.uom?.name || '');
if (
@@ -264,8 +204,6 @@ const SalesOrderProductForm = ({
}
handleBlurField('qty');
} else {
setSelectedProductWarehouse(null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('qty', '');
formik.setFieldValue('uom', '');
formik.setFieldValue('week', null);
@@ -279,12 +217,9 @@ const SalesOrderProductForm = ({
formik.resetForm({
values: {
vehicle_number: '',
warehouse_id: undefined,
warehouse: null,
kandang_id: undefined,
kandang: null,
product_warehouse: null,
product_warehouse_data: null,
product_warehouse_id: undefined,
unit_price: '',
total_weight: '',
@@ -375,10 +310,6 @@ const SalesOrderProductForm = ({
handleBlurField('week');
}, [formik.values.week]);
useEffect(() => {
setSelectedProductWarehouse(initialValues?.product_warehouse_data || null);
}, [initialValues?.product_warehouse_data]);
return (
<>
<form
@@ -417,22 +348,22 @@ const SalesOrderProductForm = ({
errorMessage={formik.errors.vehicle_number}
/>
{/* Gudang Fisik */}
{/* Gudang */}
<SelectInputRadio
required
label='Gudang Fisik'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={formik.values.warehouse}
onChange={warehouseChangeHandler}
label='Gudang'
options={kandangSourceOptions}
isLoading={isLoadingKandangSourceOptions}
value={formik.values.kandang}
onChange={kandangChangeHandler}
isClearable
onInputChange={setWarehouseSearchValue}
onMenuScrollToBottom={loadMoreWarehouses}
onInputChange={setKandangInputValue}
onMenuScrollToBottom={loadMoreKandang}
isError={
formik.touched.warehouse_id && Boolean(formik.errors.warehouse_id)
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
errorMessage={formik.errors.warehouse_id}
placeholder='Pilih Gudang Fisik'
errorMessage={formik.errors.kandang_id}
placeholder='Pilih Gudang'
/>
{/* Kategori */}
@@ -443,9 +374,8 @@ const SalesOrderProductForm = ({
value={formik.values.marketing_type}
onChange={(val) => {
formik.setFieldValue('marketing_type', val);
productWarehouseChangeHandler(null);
warehouseChangeHandler(null);
formik.setFieldValue('product_warehouse', null);
formik.setFieldValue('product_warehouse_data', null);
formik.setFieldValue('product_warehouse_id', null);
formik.setFieldValue('convertion_unit', null);
formik.setFieldValue('weight_per_convertion', null);
@@ -462,18 +392,18 @@ const SalesOrderProductForm = ({
options={productOptionsFiltered}
isLoading={isLoadingWarehouseSourceOptions}
value={formik.values.product_warehouse}
onChange={productWarehouseChangeHandler}
onChange={warehouseChangeHandler}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouse}
isClearable
placeholder={
formik.values.warehouse_id
formik.values.kandang_id
? productOptionsFiltered.length == 0
? 'Tidak ada produk yang tersedia'
: 'Pilih produk'
: 'Pilih Gudang Fisik Terlebih Dahulu'
: 'Pilih Kandang Terlebih Dahulu'
}
isDisabled={!formik.values.warehouse_id}
isDisabled={!formik.values.kandang_id}
isError={
formik.touched.product_warehouse_id &&
Boolean(formik.errors.product_warehouse_id)
@@ -541,7 +471,7 @@ const SalesOrderProductForm = ({
<input
type='radio'
checked={
formik.values.convertion_unit?.value.toLowerCase() ===
formik.values.convertion_unit?.value ===
option.value
}
onChange={() => null}
@@ -564,9 +494,7 @@ const SalesOrderProductForm = ({
} per ${formik.values.convertion_unit?.value}`}
value={formik.values.weight_per_convertion ?? ''}
onChange={(e) => {
const value = Number(e.target.value)
? Number(e.target.value)
: '';
const value = Number(e.target.value);
handleFieldChange('weight_per_convertion', value, () =>
setCurrentInput(e.target.name)
);
@@ -620,7 +548,7 @@ const SalesOrderProductForm = ({
placeholder='Masukan Total Peti'
endAdornment={
<div className='flex items-center gap-2'>
<span className='text-sm text-base-content/50'>Peti</span>
<span className='text-sm text-base-content/50'>Kg</span>
</div>
}
bottomLabel={`1 ${formik.values.convertion_unit?.value.toLowerCase()} = ${formik.values.weight_per_convertion ?? 0} Kg`}
@@ -670,9 +598,6 @@ const SalesOrderProductForm = ({
}
errorMessage={formik.errors.total_weight}
placeholder='Masukan Total Bobot'
disabled={
formik.values.convertion_unit?.value.toLowerCase() === 'peti'
}
/>
)}
@@ -740,34 +665,12 @@ const SalesOrderProductForm = ({
/>
)}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label.toLowerCase() !== 'qty' ? 'Kg' : 'Butir'} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan...'
/>
)}
{/* Harga per kg untuk TELUR + QTY */}
{/* Harga per butir untuk TELUR + QTY */}
{formik.values.marketing_type?.value.toLowerCase() === 'telur' &&
formik.values.convertion_unit?.value.toLowerCase() === 'qty' && (
<NumberInput
required
label='Harga / Kg (Rp)'
label='Harga / Butir (Rp)'
name='price_per_qty'
value={formik.values.price_per_qty ?? undefined}
onChange={(e) => {
@@ -781,7 +684,29 @@ const SalesOrderProductForm = ({
Boolean(formik.errors.price_per_qty)
}
errorMessage={formik.errors.price_per_qty}
placeholder='Masukan Harga per Kg'
placeholder='Masukan Harga per Butir'
/>
)}
{/* Harga Satuan per Uom Produk Warehouse */}
{formik.values.convertion_unit?.value.toLowerCase() !== 'peti' &&
formik.values.convertion_unit?.value.toLowerCase() !== 'kg' && (
<NumberInput
required
label={`Harga / ${formik.values.convertion_unit?.label !== 'qty' ? 'Kg' : (selectedProductWarehouse?.product?.uom?.name ?? 'Produk')} (Rp)`}
name='unit_price'
value={formik.values.unit_price}
onChange={(e) => {
const value = Number(e.target.value);
handleFieldChange('unit_price', value, () =>
setCurrentInput(e.target.name)
);
}}
isError={
formik.touched.unit_price && Boolean(formik.errors.unit_price)
}
errorMessage={formik.errors.unit_price}
placeholder='Masukan Harga Satuan'
/>
)}
@@ -5,9 +5,8 @@ import { Icon } from '@iconify/react';
import { useRef, useMemo } from 'react';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
import DeliveryOrderExport from '@/components/pages/marketing/pdf/DeliveryOrderExport';
import { Marketing } from '@/types/api/marketing/marketing';
import { Marketing, BaseDelivery } from '@/types/api/marketing/marketing';
import { Warehouse } from '@/types/api/master-data/warehouse';
import { DeliveryProductToFieldValues } from '@/components/pages/marketing/form/MarketingForm.schema';
type DeliveryOrderProductTableProps = {
data: DeliveryOrderProductFormValues[];
@@ -56,17 +55,14 @@ const DeliveryOrderProductTable = ({
const deliveryItems = useMemo(() => {
if (!hasDeliveryOrder) return [];
return (
marketing?.delivery_order?.flatMap((doItem) =>
DeliveryProductToFieldValues(marketing?.sales_order, doItem).map(
(delivery) => ({
...delivery,
do_number: doItem.do_number,
delivery_date: doItem.delivery_date,
warehouse: doItem.warehouse,
})
)
doItem.deliveries.map((delivery) => ({
...delivery,
do_number: doItem.do_number,
delivery_date: doItem.delivery_date,
warehouse: doItem.warehouse,
}))
) ?? []
);
}, [marketing?.delivery_order, hasDeliveryOrder]);
@@ -85,7 +81,7 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{/* {formType !== 'success' &&
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
@@ -102,16 +98,15 @@ const DeliveryOrderProductTable = ({
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)} */}
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>
{doItem?.warehouse?.name ||
item.marketing_product?.warehouse?.label ||
item.marketing_product?.product_warehouse_data?.warehouse?.name}
</td>
</tr>
@@ -124,7 +119,7 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<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 ?? ''}`
: '-'}
</td>
@@ -141,15 +136,12 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Total Bobot</td>
<td className='text-sm px-4 py-3'>
{formatNumber(Number(item.total_weight))} Kg
{formatNumber(Number(item.total_weight))}
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>
Total Harga Satuan
{item.convertion_unit?.label.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
@@ -219,7 +211,7 @@ const DeliveryOrderProductTable = ({
};
const renderDeliveryOrderContent = (
item: DeliveryOrderProductFormValues & {
item: BaseDelivery & {
do_number: string;
delivery_date: string;
warehouse: Warehouse;
@@ -238,43 +230,25 @@ const DeliveryOrderProductTable = ({
<th className='text-start font-medium text-base-content/50 text-sm px-4 py-3'>
<div className='flex w-full flex-row gap-1 items-center justify-between h-full'>
<div>Value</div>
{formType !== 'success' &&
(formType === 'add_delivery' ||
formType === 'edit_delivery' ||
formType === 'detail') && (
<div className='flex flex-row gap-1.5 items-center'>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
onEditRef.current(item.id as number, item);
}}
className='p-0 hover:text-base-content'
>
<Icon icon='heroicons:pencil' width={20} height={20} />
</Button>
</div>
)}
</div>
</th>
</tr>
<>
<tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.warehouse?.name}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Produk</td>
<td className='text-sm px-4 py-3'>
{item.marketing_product?.product_warehouse_data?.product.name}
{item.product_warehouse?.product?.name}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Qty</td>
<td className='text-sm px-4 py-3'>
{item.qty !== undefined && item.qty !== null && item.qty !== ''
? `${formatNumber(Number(item.qty))} ${item.marketing_product?.product_warehouse_data?.product.uom.name ?? ''}`
{item.qty
? `${formatNumber(item.qty)} ${item.product_warehouse?.product?.uom?.name ?? ''}`
: '-'}
</td>
</tr>
@@ -297,13 +271,13 @@ const DeliveryOrderProductTable = ({
<tr>
<td className='text-sm px-4 py-3'>Total Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.unit_price))}
{formatCurrency(item.unit_price)}
</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Total Penjualan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(Number(item.total_price))}
{formatCurrency(item.total_price)}
</td>
</tr>
</>
@@ -359,9 +333,7 @@ const DeliveryOrderProductTable = ({
<div className='size-full flex flex-col relative overflow-x-hidden gap-3'>
{hasDeliveryOrder
? deliveryItems.map((item, index) => (
<div
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
>
<div key={`do-table-${item.product_warehouse?.id}-${index}`}>
{formType === 'success' ? (
<div className='rounded-lg border border-tools-table-outline border-base-content/5'>
<table
@@ -377,11 +349,8 @@ const DeliveryOrderProductTable = ({
</div>
) : (
<Card
key={`do-table-${item.marketing_product?.product_warehouse?.value}-${index}`}
title={
item.marketing_product?.product_warehouse_data?.product
.name || 'Produk'
}
key={`do-table-${item.product_warehouse?.id}-${index}`}
title={item.product_warehouse?.product?.name || 'Produk'}
collapsible={true}
defaultCollapsed={false}
variant='bordered'
@@ -73,10 +73,8 @@ const SalesOrderProductTable = ({
<td className='text-sm px-4 py-3'>{item.vehicle_number}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Gudang Fisik</td>
<td className='text-sm px-4 py-3'>
{item.warehouse?.label ?? item.kandang?.label}
</td>
<td className='text-sm px-4 py-3'>Gudang</td>
<td className='text-sm px-4 py-3'>{item.kandang?.label}</td>
</tr>
<tr>
<td className='text-sm px-4 py-3'>Kategori</td>
@@ -137,22 +135,8 @@ const SalesOrderProductTable = ({
{`${formatNumber(parseFloat(item.qty as string))} ${item.uom || ''}`}
</td>
</tr>
{item.convertion_unit?.value.toLowerCase() === 'peti' && (
<tr>
<td className='text-sm px-4 py-3'>Harga Satuan Per Peti</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(
parseFloat(item.unit_price as string) *
parseFloat(String(item.weight_per_convertion))
)}
</td>
</tr>
)}
<tr>
<td className='text-sm px-4 py-3'>
Harga Satuan
{item.convertion_unit?.value.toLowerCase() === 'peti' && ' (Kg)'}
</td>
<td className='text-sm px-4 py-3'>Harga Satuan</td>
<td className='text-sm px-4 py-3'>
{formatCurrency(parseFloat(item.unit_price as string))}
</td>
@@ -101,8 +101,8 @@ const PDFDocument = ({
<View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
@@ -87,8 +87,8 @@ const PDFDocument = ({ data }: { data: Marketing }) => {
<View style={pdfStyles.header}>
<Text style={pdfStyles.companyInfo}>PT LUMBUNG TELUR INDONESIA</Text>
<Text style={pdfStyles.address}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const AreasTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,14 +114,12 @@ const AreasTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'areas-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -134,8 +137,17 @@ const AreasTable = () => {
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('areas-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const BanksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,14 +114,12 @@ const BanksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'banks-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -134,8 +137,17 @@ const BanksTable = () => {
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('banks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const CustomersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,14 +114,12 @@ const CustomersTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'customers-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -136,8 +139,17 @@ const CustomersTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('customers-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const FlockTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,14 +114,12 @@ const FlockTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'flock-table',
});
const [sorting, setSorting] = useState<SortingState>([]);
@@ -136,8 +139,17 @@ const FlockTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('flocks-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,13 @@
'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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -28,6 +35,7 @@ import { User } from '@/types/api/api-general';
import { formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
KandangFilterSchema,
KandangFilterType,
@@ -114,21 +122,20 @@ const RowOptionsMenu = ({
};
const KandangsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined,
locationFilter: '',
picFilter: '',
},
paramMap: {
page: 'page',
@@ -136,8 +143,6 @@ const KandangsTable = () => {
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// ===== FILTER MODAL STATE =====
@@ -146,34 +151,22 @@ const KandangsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<KandangFilterType>({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
location_id: null,
pic_id: null,
},
validationSchema: KandangFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('locationFilter', values.location || undefined, true);
updateFilter('picFilter', values.pic || undefined, true);
updateFilter('locationFilter', values.location_id || '');
updateFilter('picFilter', values.pic_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('locationFilter', '');
updateFilter('picFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('locationFilter', undefined, true);
updateFilter('picFilter', undefined, true);
formik.resetForm({
values: {
location: undefined,
pic: undefined,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== LOCATION OPTIONS =====
const {
setInputValue: setLocationInputValue,
@@ -201,15 +194,43 @@ const KandangsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('location', val);
};
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
setFieldValue('pic', val);
};
formik.setFieldValue('location_id', locationId);
},
[formik]
);
const handleFilterPicChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const pic = val as OptionType | null;
const picId = pic?.value ? String(pic.value) : null;
formik.setFieldValue('pic_id', picId);
},
[formik]
);
// ===== FILTER HELPERS =====
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const picIdValue = useMemo(() => {
if (!formik.values.pic_id) return null;
return (
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
null
);
}, [formik.values.pic_id, picOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -234,8 +255,17 @@ const KandangsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('kandangs-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -284,10 +314,6 @@ const KandangsTable = () => {
accessorFn: (row) => row.pic?.name ?? '-',
header: 'PIC',
},
{
accessorFn: (row) => row.kandang_group?.name ?? '-',
header: 'Kandang Group',
},
{
header: 'Aksi',
cell: (props: CellContext<Kandang, unknown>) => {
@@ -445,13 +471,13 @@ const KandangsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.location}
value={locationIdValue}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
@@ -464,7 +490,7 @@ const KandangsTable = () => {
label='PIC'
placeholder='Pilih PIC'
options={picOptions}
value={formik.values.pic}
value={picIdValue}
onChange={handleFilterPicChange}
onInputChange={setPicInputValue}
isLoading={isLoadingPicOptions}
@@ -480,14 +506,17 @@ const KandangsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={formikResetHandler}
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid}
disabled={!formik.isValid || formik.isSubmitting}
>
Apply Filter
</Button>
@@ -1,19 +1,11 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const KandangFilterSchema = Yup.object().shape({
location: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
pic: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const KandangFilterSchema = object().shape({
location_id: string().nullable(),
pic_id: string().nullable(),
});
export type KandangFilterType = {
location?: OptionType<string>;
pic?: OptionType<string>;
location_id: string | null;
pic_id: string | null;
};
@@ -1,4 +1,3 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
type KandangFormSchemaType = {
@@ -20,7 +19,6 @@ type KandangFormSchemaType = {
}
| undefined
| null;
group?: OptionType;
};
export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
@@ -44,11 +42,6 @@ export const KandangFormSchema: Yup.ObjectSchema<KandangFormSchemaType> =
value: Yup.number().min(1).required(),
label: Yup.string().required(),
}).nullable(),
group: Yup.object({
value: Yup.number().min(1).required('Kandang Grup wajib diisi!'),
label: Yup.string().required('Kandang Grup wajib diisi!'),
}).required('Kandang Grup wajib diisi!'),
});
export const UpdateKandangFormSchema = KandangFormSchema;
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { getIn, useFormik } from 'formik';
import { useFormik } from 'formik';
import { toast } from 'react-hot-toast';
import { Icon } from '@iconify/react';
@@ -34,8 +34,6 @@ import NumberInput from '@/components/input/NumberInput';
import { useFormikErrorList } from '@/services/hooks/useFormikErrorList';
import AlertErrorList from '@/components/helper/form/FormErrors';
import { User } from '@/types/api/api-general';
import { DailyChecklistKandang } from '@/types/api/daily-checklist/kandang';
import { DailyChecklistKandangApi } from '@/services/api/daily-checklist/kandang';
interface KandangFormProps {
type?: 'add' | 'edit' | 'detail';
@@ -98,12 +96,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
label: initialValues.pic.name,
}
: null,
group: initialValues?.kandang_group
? {
value: initialValues.kandang_group.id,
label: initialValues.kandang_group.name,
}
: undefined,
};
}, [initialValues]);
@@ -119,7 +111,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
location_id: values.locationId!,
capacity: values.capacity ? parseInt(values.capacity.toString()) : 0,
pic_id: values.picId!,
group_id: values.group?.value as number,
};
switch (type) {
@@ -171,23 +162,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
formik.setFieldValue('picId', (val as OptionType)?.value);
};
// Kandang Group
const {
setInputValue: setKandangGroupSelectInputValue,
options: kandangGroupOptions,
isLoadingOptions: isLoadingKandangGroupOptions,
loadMore: loadMoreKandangGroups,
} = useSelect<DailyChecklistKandang>(
DailyChecklistKandangApi.basePath,
'id',
'name'
);
const kandangGroupChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldTouched('group', true);
formik.setFieldValue('group', val);
};
const deleteKandangClickHandler = () => {
deleteModal.openModal();
};
@@ -295,24 +269,6 @@ const KandangForm = ({ type = 'add', initialValues }: KandangFormProps) => {
isDisabled={type === 'detail'}
isClearable
/>
<SelectInput
required
label='Kandang Group'
value={formik.values.group ?? undefined}
onChange={kandangGroupChangeHandler}
options={kandangGroupOptions}
onInputChange={setKandangGroupSelectInputValue}
onMenuScrollToBottom={loadMoreKandangGroups}
isLoading={isLoadingKandangGroupOptions}
isError={formik.touched.group && Boolean(formik.errors.group)}
errorMessage={
getIn(formik.errors.group, 'value') ??
(formik.errors.group as string)
}
isDisabled={type === 'detail'}
isClearable
/>
</div>
<div className='flex flex-row justify-between gap-2 flex-wrap'>
@@ -1,6 +1,13 @@
'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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
LocationFilterSchema,
LocationFilterType,
@@ -110,27 +118,25 @@ const RowOptionsMenu = ({
};
const LocationsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
areaFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
areaFilter: undefined,
areaFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
areaFilter: 'area_id',
},
persist: true,
storeName: 'locations-table',
});
// ===== FILTER MODAL STATE =====
@@ -139,28 +145,19 @@ const LocationsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<LocationFilterType>({
initialValues: {
area: tableFilterState.areaFilter,
area_id: null,
},
validationSchema: LocationFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area || undefined, true);
updateFilter('areaFilter', values.area_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', undefined, true);
formik.resetForm({
values: {
area: undefined,
},
});
filterModal.closeModal();
};
// ===== AREA OPTIONS =====
const {
setInputValue: setAreaInputValue,
@@ -175,9 +172,24 @@ const LocationsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area', val);
};
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
formik.setFieldValue('area_id', areaId);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -200,10 +212,19 @@ const LocationsTable = () => {
>(undefined);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('locations-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -404,13 +425,13 @@ const LocationsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={formik.values.area}
value={areaIdValue}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -426,7 +447,10 @@ const LocationsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={formikResetHandler}
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,13 +1,9 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const LocationFilterSchema = Yup.object().shape({
area: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const LocationFilterSchema = object().shape({
area_id: string().nullable(),
});
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const NonstocksTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,16 +114,22 @@ const NonstocksTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'nonstock-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('nonstocks-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -137,7 +148,8 @@ const NonstocksTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,7 @@
'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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const ProductCategoryTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -115,10 +120,12 @@ const ProductCategoryTable = () => {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'product-category-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -137,7 +144,8 @@ const ProductCategoryTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -206,6 +214,10 @@ const ProductCategoryTable = () => {
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
);
useEffect(() => {
setTableState('product-category-table', pathname);
}, [pathname, setTableState]);
return (
<>
<div className='w-full'>
@@ -1,6 +1,13 @@
'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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -26,6 +33,7 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
import { formatCurrency } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
ProductFilterSchema,
ProductFilterType,
@@ -111,27 +119,25 @@ const RowOptionsMenu = ({
};
const ProductsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
productCategoryFilter?: OptionType<string>;
}>({
} = useTableFilter({
initial: {
search: '',
productCategoryFilter: undefined,
productCategoryFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
productCategoryFilter: 'product_category_id',
},
persist: true,
storeName: 'product-table',
});
// ===== FILTER MODAL STATE =====
@@ -140,32 +146,19 @@ const ProductsTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<ProductFilterType>({
initialValues: {
product_category: tableFilterState.productCategoryFilter,
product_category_id: null,
},
validationSchema: ProductFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter(
'productCategoryFilter',
values.product_category || undefined,
true
);
updateFilter('productCategoryFilter', values.product_category_id || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('productCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('productCategoryFilter', undefined, true);
formik.resetForm({
values: {
product_category: undefined,
},
});
filterModal.closeModal();
};
// ===== PRODUCT CATEGORY OPTIONS =====
const {
setInputValue: setProductCategoryInputValue,
@@ -180,11 +173,25 @@ const ProductsTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterProductCategoryChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('product_category', val);
};
const handleFilterProductCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const category = val as OptionType | null;
const categoryId = category?.value ? String(category.value) : null;
formik.setFieldValue('product_category_id', categoryId);
},
[formik]
);
// ===== FILTER HELPERS =====
const productCategoryIdValue = useMemo(() => {
if (!formik.values.product_category_id) return null;
return (
productCategoryOptions.find(
(opt) => String(opt.value) === formik.values.product_category_id
) || null
);
}, [formik.values.product_category_id, productCategoryOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -192,6 +199,10 @@ const ProductsTable = () => {
formik.validateForm();
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -209,8 +220,13 @@ const ProductsTable = () => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setTableState('product-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -461,13 +477,13 @@ const ProductsTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Kategori Produk'
placeholder='Pilih Kategori Produk'
options={productCategoryOptions}
value={formik.values.product_category}
value={productCategoryIdValue}
onChange={handleFilterProductCategoryChange}
onInputChange={setProductCategoryInputValue}
isLoading={isLoadingProductCategoryOptions}
@@ -483,7 +499,10 @@ const ProductsTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={formikResetHandler}
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,13 +1,9 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const ProductFilterSchema = Yup.object().shape({
product_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const ProductFilterSchema = object().shape({
product_category_id: string().nullable(),
});
export type ProductFilterType = {
product_category?: OptionType<string>;
product_category_id: string | null;
};
@@ -154,17 +154,17 @@ const ProductForm = ({ type = 'add', initialValues }: ProductFormProps) => {
sku: values.sku,
uom_id: values.uom_id,
product_category_id: values.product_category_id,
product_price: parseFloat(values.product_price.toString()) || 0,
product_price: parseInt(values.product_price.toString()) || 0,
selling_price: values.selling_price
? parseFloat(values.selling_price.toString()) || 0
? parseInt(values.selling_price.toString()) || 0
: undefined,
tax: values.tax ? parseFloat(values.tax.toString()) || 0 : undefined,
tax: values.tax ? parseInt(values.tax.toString()) || 0 : undefined,
expiry_period: values.expiry_period
? parseFloat(values.expiry_period.toString()) || 0
? parseInt(values.expiry_period.toString()) || 0
: undefined,
suppliers: values.suppliers.map((s) => ({
supplier_id: s.supplier?.value as number,
price: parseFloat(s.price.toString()) || 0,
price: parseInt(s.price.toString()) || 0,
})),
flag: values.flag,
sub_flags: values.sub_flags,
@@ -128,44 +128,27 @@ const ProductionStandardTable = () => {
pageSize: 'limit',
projectCategoryFilter: 'project_category',
},
persist: true,
storeName: 'production-standard-table',
});
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
const filterInitialValues = useMemo<ProductionStandardFilterType>(
() => ({
project_category: tableFilterState.projectCategoryFilter || null,
}),
[tableFilterState.projectCategoryFilter]
);
// ===== FORMIK SETUP =====
const formik = useFormik<ProductionStandardFilterType>({
initialValues: filterInitialValues,
initialValues: {
project_category: null,
},
validationSchema: ProductionStandardFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('projectCategoryFilter', values.project_category || '');
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('projectCategoryFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('projectCategoryFilter', '', true);
formik.resetForm({
values: {
project_category: null,
},
});
filterModal.closeModal();
};
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
const projectCategoryOptions = useMemo(
() => [
@@ -398,7 +381,7 @@ const ProductionStandardTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
@@ -414,9 +397,13 @@ const ProductionStandardTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -7,6 +7,7 @@ import {
useMemo,
useState,
} from 'react';
import { usePathname } from 'next/navigation';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import toast from 'react-hot-toast';
@@ -29,7 +30,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
import { SupplierApi } from '@/services/api/master-data';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
SupplierFilterSchema,
SupplierFilterType,
@@ -116,21 +117,20 @@ const RowOptionsMenu = ({
};
const SuppliersTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<{
search: string;
categoryFilter?: OptionType<string>;
flagFilter?: string;
}>({
} = useTableFilter({
initial: {
search: '',
categoryFilter: undefined,
flagFilter: undefined,
categoryFilter: '',
flagFilter: '',
},
paramMap: {
page: 'page',
@@ -138,8 +138,6 @@ const SuppliersTable = () => {
categoryFilter: 'category_id',
flagFilter: 'flag',
},
persist: true,
storeName: 'supplier-table',
});
// ===== FILTER MODAL STATE =====
@@ -148,33 +146,26 @@ const SuppliersTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<SupplierFilterType>({
initialValues: {
category: tableFilterState.categoryFilter,
flag: tableFilterState.flagFilter === 'EKSPEDISI',
category_id: null,
flag: false,
},
validationSchema: SupplierFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('categoryFilter', values.category || undefined, true);
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
updateFilter('categoryFilter', values.category_id || '');
updateFilter(
'flagFilter',
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('categoryFilter', '');
updateFilter('flagFilter', '');
formik.setFieldValue('flag', false);
},
});
const formikResetHandler = () => {
updateFilter('categoryFilter', undefined, true);
updateFilter('flagFilter', '', true);
formik.resetForm({
values: {
category: undefined,
flag: false,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
@@ -196,11 +187,15 @@ const SuppliersTable = () => {
);
// ===== FILTER HANDLERS =====
const handleFilterCategoryChange = (
val: OptionType | OptionType[] | null
) => {
setFieldValue('category', val);
};
const handleFilterCategoryChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const option = val as OptionType | null;
const categoryId = option?.value ? String(option.value) : null;
setFieldValue('category_id', categoryId);
},
[setFieldValue]
);
const handleFilterFlagChange = useCallback(
(val: OptionType | OptionType[] | null) => {
@@ -218,13 +213,13 @@ const SuppliersTable = () => {
);
// ===== FILTER HELPERS =====
// const categoryIdValue = useMemo(() => {
// if (!formik.values.category_id) return null;
// return (
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
// null
// );
// }, [formik.values.category_id, categoryOptions]);
const categoryIdValue = useMemo(() => {
if (!formik.values.category_id) return null;
return (
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
null
);
}, [formik.values.category_id, categoryOptions]);
const flagValue = useMemo(() => {
if (formik.values.flag === null) return null;
@@ -248,6 +243,14 @@ const SuppliersTable = () => {
}
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('suppliers-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -266,7 +269,8 @@ const SuppliersTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -487,13 +491,13 @@ const SuppliersTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInputRadio
label='Kategori'
placeholder='Pilih Kategori'
options={categoryOptions}
value={formik.values.category}
value={categoryIdValue}
onChange={handleFilterCategoryChange}
isClearable
className={{ wrapper: 'w-full' }}
@@ -513,9 +517,13 @@ const SuppliersTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -1,16 +1,11 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, boolean, object } from 'yup';
export const SupplierFilterSchema = Yup.object().shape({
category: Yup.object({
value: Yup.string().required(),
label: Yup.string().required(),
}).nullable(),
flag: Yup.boolean().nullable(),
export const SupplierFilterSchema = object().shape({
category_id: string().nullable(),
flag: boolean().nullable(),
});
export type SupplierFilterType = {
category?: OptionType<string>;
category_id: string | null;
flag: boolean | null;
};
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
const RowOptionsMenu = ({
popoverPosition = 'bottom',
@@ -101,6 +103,9 @@ const RowOptionsMenu = ({
};
const UomsTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -109,16 +114,22 @@ const UomsTable = () => {
toQueryString: getTableFilterQueryString,
} = useTableFilter({
initial: {
search: '',
search: searchValue,
},
paramMap: {
page: 'page',
pageSize: 'limit',
},
persist: true,
storeName: 'uom-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('uoms-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -135,7 +146,8 @@ const UomsTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -1,6 +1,13 @@
'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 { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
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 { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import { useUiStore } from '@/stores/ui/ui.store';
import {
WarehouseFilterSchema,
WarehouseFilterType,
@@ -112,6 +120,9 @@ const RowOptionsMenu = ({
};
const WarehousesTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
@@ -130,8 +141,6 @@ const WarehousesTable = () => {
areaFilter: 'area_id',
activeProjectFlockFilter: 'active_project_flock',
},
persist: true,
storeName: 'warehouses-table',
});
// ===== FILTER MODAL STATE =====
@@ -140,36 +149,27 @@ const WarehousesTable = () => {
// ===== FORMIK SETUP =====
const formik = useFormik<WarehouseFilterType>({
initialValues: {
area_id: tableFilterState.areaFilter || null,
active_project_flock:
tableFilterState.activeProjectFlockFilter === 'true',
area_id: null,
active_project_flock: false,
},
validationSchema: WarehouseFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id || '', true);
updateFilter('areaFilter', values.area_id || '');
updateFilter(
'activeProjectFlockFilter',
values.active_project_flock === true ? 'true' : '',
true
values.active_project_flock === true ? 'true' : ''
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('activeProjectFlockFilter', '');
formik.setFieldValue('active_project_flock', false);
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', '', true);
updateFilter('activeProjectFlockFilter', '', true);
formik.resetForm({
values: {
area_id: null,
active_project_flock: false,
},
});
filterModal.closeModal();
};
const { setFieldValue } = formik;
// ===== AREA OPTIONS =====
const {
@@ -243,6 +243,26 @@ const WarehousesTable = () => {
formik.validateForm();
};
useEffect(() => {
if (filterModal.open) {
const activeProjectFlockValue =
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
setFieldValue('active_project_flock', activeProjectFlockValue);
}
}, [
filterModal.open,
tableFilterState.activeProjectFlockFilter,
setFieldValue,
]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('warehouses-table', pathname);
}, [pathname, setTableState]);
const [sorting, setSorting] = useState<SortingState>([]);
const {
@@ -261,7 +281,8 @@ const WarehousesTable = () => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmationModalDeleteClickHandler = async () => {
@@ -486,7 +507,7 @@ const WarehousesTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
@@ -517,7 +538,10 @@ const WarehousesTable = () => {
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={formikResetHandler}
onClick={() => {
formik.resetForm();
filterModal.closeModal();
}}
>
Reset Filter
</Button>
@@ -11,6 +11,7 @@ import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import Table from '@/components/Table';
import Dropdown from '@/components/Dropdown';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { cn, formatDate } from '@/lib/helper';
import { AreaApi, KandangApi, LocationApi } from '@/services/api/master-data';
@@ -22,6 +23,7 @@ import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
import { useRouter, usePathname } from 'next/navigation';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { useUiStore } from '@/stores/ui/ui.store';
import toast from 'react-hot-toast';
import useSWR from 'swr';
import { useFormik } from 'formik';
@@ -43,7 +45,6 @@ import {
import Modal from '@/components/Modal';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import ButtonFilter from '@/components/helper/ButtonFilter';
import NumberInput from '@/components/input/NumberInput';
const RowOptionsMenu = ({
props,
@@ -58,7 +59,8 @@ const RowOptionsMenu = ({
detailClickHandler: (id: number) => void;
deleteClickHandler: () => void;
}) => {
const showEditButton = props.row.original.approval?.step_number !== 2;
// TODO: change this to real condition
const showEditButton = true;
const showDeleteButton = showEditButton;
@@ -147,6 +149,7 @@ const RowOptionsMenu = ({
};
const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const isSuccess = useProjectFlockStore((s) => s.isSuccess);
@@ -172,9 +175,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
kandang_id: '',
category: '',
period: '',
area_name: '',
location_name: '',
kandang_name: '',
},
paramMap: {
page: 'page',
@@ -186,11 +186,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
category: 'category',
period: 'period',
},
excludeKeysFromUrl: ['area_name', 'location_name', 'kandang_name'],
persist: true,
storeName: 'project-flock-table',
});
const router = useRouter();
// ===== State =====
@@ -211,7 +207,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isApproveLoading, setIsApproveLoading] = useState(false);
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const {
isChickinApproveModalOpen,
isChickinApproveLoading,
@@ -261,18 +258,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', values.kandang_id || '');
updateFilter('category', values.category || '');
updateFilter('period', values.period || '');
updateFilter(
'area_name',
areaValue?.label ? String(areaValue.label) : ''
);
updateFilter(
'location_name',
locationValue?.label ? String(locationValue.label) : ''
);
updateFilter(
'kandang_name',
kandangValue?.label ? String(kandangValue.label) : ''
);
filterModal.closeModal();
setSubmitting(false);
},
@@ -282,9 +267,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
updateFilter('kandang_id', '');
updateFilter('category', '');
updateFilter('period', '');
updateFilter('area_name', '');
updateFilter('location_name', '');
updateFilter('kandang_name', '');
setFilterAreaId(undefined);
setFilterLocationId(undefined);
filterModal.closeModal();
@@ -326,55 +308,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const periodOptions = useMemo(
() => [
{ value: '1', label: 'Periode 1' },
{ value: '2', label: 'Periode 2' },
],
[]
);
// ===== FILTER HELPERS =====
const areaValue = useMemo(() => {
if (!formik.values.area_id) return null;
const found = areaOptions.find(
(opt) => String(opt.value) === formik.values.area_id
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
if (found) return found;
if (tableFilterState.area_name) {
return {
value: formik.values.area_id,
label: tableFilterState.area_name,
};
}
return null;
}, [formik.values.area_id, areaOptions, tableFilterState.area_name]);
}, [formik.values.area_id, areaOptions]);
const locationValue = useMemo(() => {
if (!formik.values.location_id) return null;
const found = locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
if (found) return found;
if (tableFilterState.location_name) {
return {
value: formik.values.location_id,
label: tableFilterState.location_name,
};
}
return null;
}, [
formik.values.location_id,
locationOptions,
tableFilterState.location_name,
]);
}, [formik.values.location_id, locationOptions]);
const kandangValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
const found = kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
);
if (found) return found;
if (tableFilterState.kandang_name) {
return {
value: formik.values.kandang_id,
label: tableFilterState.kandang_name,
};
}
return null;
}, [formik.values.kandang_id, kandangOptions, tableFilterState.kandang_name]);
}, [formik.values.kandang_id, kandangOptions]);
const categoryValue = useMemo(() => {
if (!formik.values.category) return null;
@@ -384,6 +351,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
);
}, [formik.values.category, categoryOptions]);
const periodValue = useMemo(() => {
if (!formik.values.period) return null;
return (
periodOptions.find((opt) => opt.value === formik.values.period) || null
);
}, [formik.values.period, periodOptions]);
// ===== FILTER DEPENDENCY HANDLERS =====
const handleFilterAreaChange = (area: OptionType | null) => {
const areaId = area?.value ? String(area.value) : undefined;
@@ -452,11 +426,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
setIsDeleteLoading(false);
setRowSelection({});
};
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('project-flock-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
updateFilter('search', e.target.value, true);
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
};
const confirmApprovalHandler = async (
notes: string,
approvalAction: 'APPROVED' | 'REJECTED'
@@ -574,7 +555,6 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
price: budget.price,
total_price: budget.qty * budget.price,
})) || [],
periode: createdProjectFlock.period ?? '-',
} as ProjectFlockFormValues;
}, [createdProjectFlock]);
@@ -797,6 +777,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
[]
);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
toast.error('Not implemented yet!');
setIsLoadingExportingToExcel(false);
};
const bulkApproveClickHandler = () => {
setApprovalAction('APPROVED');
confirmModal.openModal();
@@ -985,17 +973,55 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'area_name',
'location_name',
'kandang_name',
]}
excludeFields={['page', 'pageSize', 'search']}
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
</Dropdown>
</div>
</div>
@@ -1324,14 +1350,18 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => {
isClearable={true}
/>
<NumberInput
<SelectInputRadio
label='Periode'
name='period'
placeholder='Masukkan Periode'
value={formik.values.period ?? ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder='Pilih Periode'
options={periodOptions}
value={periodValue}
onChange={(val) => {
if (!Array.isArray(val)) {
formik.setFieldValue('period', val?.value || null);
}
}}
className={{ wrapper: 'w-full' }}
isClearable
/>
</div>
@@ -26,7 +26,6 @@ type ProjectFlockFormSchemaType = {
label: string;
} | null;
location_id: number;
periode: number | string;
kandang_ids: number[];
project_budgets: ProjectFlockBudgetsSchemaType[];
};
@@ -110,12 +109,6 @@ export const ProjectFlockFormSchema: Yup.ObjectSchema<ProjectFlockFormSchemaType
.min(1, 'Lokasi wajib diisi!')
.required('Lokasi wajib diisi!'),
// Period
periode: Yup.number()
.typeError('Periode harus berupa angka!')
.min(1, 'Periode minimal 1!')
.required('Periode wajib diisi!'),
kandang_ids: Yup.array()
.of(Yup.number().required('Kandang tidak valid!'))
.min(1, 'Minimal harus ada 1 kandang!')
@@ -152,10 +152,6 @@ export const ProjectFlockFormConfirmationTable = ({
label: 'Standar Produksi',
value: projectFlockForm?.production_standard?.label ?? '-',
},
{
label: 'Periode',
value: projectFlockForm?.periode ?? '-',
},
{
label: 'Informasi Kandang',
value: '',
@@ -265,7 +261,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingFlocks,
options: optionsFlock,
loadMore: loadMoreFlock,
} = useSelect(FlockApi.basePath, 'id', 'name', 'search', {
} = useSelect(FlockApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
location_id: selectedLocation,
area_id: selectedArea,
@@ -283,7 +279,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingLocations,
setInputValue: setInputValueLocation,
loadMore: loadMoreLocation,
} = useSelect(LocationApi.basePath, 'id', 'name', 'search', {
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
? selectedArea
@@ -295,7 +291,7 @@ const ProjectFlockForm = ({
isLoadingOptions: isLoadingProductionStandards,
setInputValue: setInputValueProductionStandard,
loadMore: loadMoreProductionStandard,
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', 'search', {
} = useSelect(ProductionStandardApi.basePath, 'id', 'name', '', {
project_category: selectedCategory,
});
@@ -311,7 +307,7 @@ const ProjectFlockForm = ({
} = useSWR(kandangUrl, KandangApi.getAllFetcher);
const { data: periodFlocks, mutate: refreshPeriodFlocks } = useSWR(
selectedFlock ? `${selectedFlock?.toString()}/periods` : undefined,
`${selectedFlock?.toString()}/periods`,
() => ProjectFlockApi.getNextPeriod(parseInt(selectedLocation as string))
);
@@ -533,7 +529,6 @@ const ProjectFlockForm = ({
kandang_ids: initialValues?.kandangs?.map(
(k: Kandang) => k.id
) as number[],
periode: initialValues?.period ?? '',
project_budgets: initialValues?.project_budgets?.map((budget) => {
return {
nonstock: {
@@ -573,7 +568,6 @@ const ProjectFlockForm = ({
category: values.category as string,
production_standard_id: values.production_standard_id as number,
location_id: values.location_id as number,
periode: parseInt(values.periode as unknown as string),
kandang_ids: values.kandang_ids as number[],
project_budgets: values.project_budgets.flatMap((budget) => {
return {
@@ -799,7 +793,6 @@ const ProjectFlockForm = ({
formik.values.kandang_ids?.includes(kandang.id)
)?.period
: undefined;
const inputPeriod =
(initialValues?.period ?? selectedPeriod == 0) ? 1 : selectedPeriod;
@@ -1029,18 +1022,12 @@ const ProjectFlockForm = ({
isDisabled={formType != 'add'}
/>
<NumberInput
name='periode'
name='period'
label='Periode'
disabled
readOnly
placeholder='Periode Flock'
value={formik.values.periode}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
allowNegative={false}
decimalScale={0}
isError={
formik.touched.periode && Boolean(formik.errors.periode)
}
errorMessage={formik.errors.periode as string}
value={selectedLocation ? inputPeriod : ''}
/>
</div>
@@ -1,6 +1,12 @@
'use client';
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import React, {
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import useSWR from 'swr';
import { Icon } from '@iconify/react';
import { SortingState, CellContext, ColumnDef } from '@tanstack/react-table';
@@ -12,11 +18,9 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal';
import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes';
import { OptionType } from '@/components/input/SelectInput';
import SelectInput, { useSelect } from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import Tooltip from '@/components/Tooltip';
import { useFormik } from 'formik';
import { AreaApi } from '@/services/api/master-data';
import { LocationApi } from '@/services/api/master-data';
@@ -32,16 +36,16 @@ import {
import RecordingTableSkeleton from '@/components/pages/production/recording/skeleton/RecordingTableSkeleton';
import Table from '@/components/Table';
import { type Recording } from '@/types/api/production/recording';
import { getRecordingRestriction } from './recording-utils';
import { RecordingApi } from '@/services/api/production';
import { getErrorMessage, isResponseSuccess } from '@/lib/api-helper';
import { isResponseSuccess } from '@/lib/api-helper';
import { useTableFilter } from '@/services/hooks/useTableFilter';
import toast from 'react-hot-toast';
import StatusBadge from '@/components/helper/StatusBadge';
import CheckboxInput from '@/components/input/CheckboxInput';
import { useUiStore } from '@/stores/ui/ui.store';
import { usePathname } from 'next/navigation';
import { Color } from '@/types/theme';
import ButtonFilter from '@/components/helper/ButtonFilter';
import Dropdown from '@/components/Dropdown';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -68,26 +72,6 @@ const getStatusBadgeColor = (status: string): Color => {
return statusBadgeColorMap[normalizedStatus] || 'neutral';
};
const isRecordingApproved = (recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
};
// ===== FILTER HELPERS =====
const recordingApprovalStatusOptions: OptionType<string>[] = [
{ value: 'CREATED', label: 'Pengajuan' },
{ value: 'UPDATED', label: 'Diperbarui' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const projectFlockCategoryOptions: OptionType<string>[] = [
{ value: 'GROWING', label: 'Growing' },
{ value: 'LAYING', label: 'Laying' },
];
const RowOptionsMenu = ({
popoverPosition = 'bottom',
props,
@@ -121,75 +105,30 @@ const RowOptionsMenu = ({
};
const isRecordingEditable = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return false;
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
const restriction = getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
if (restriction.isLocked) {
return false;
if (recording.project_flock?.project_flock_category === 'GROWING') {
if (recording.transfer_executed) {
return false;
}
return recording.population_can_change === true;
}
return true;
};
const getRecordingRestrictionInfo = (recording: Recording) => {
const isGrowingCategory =
recording.project_flock?.project_flock_category === 'GROWING';
const isGrowingLockedByLaying = isGrowingCategory && recording.is_laying;
if (isGrowingLockedByLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing tidak dapat diubah karena sudah masuk fase laying dan dipakai pada recording laying',
};
}
const currentIsLaying =
recording.project_flock?.project_flock_category === 'LAYING';
return getRecordingRestriction(
recording.is_laying,
recording.is_transition,
currentIsLaying
);
};
const isApproved = isRecordingApproved(props.row.original);
const isRejected = isRecordingRejected(props.row.original);
const isEditable = isRecordingEditable(props.row.original);
const restrictionInfo = getRecordingRestrictionInfo(props.row.original);
return (
<div className='relative'>
<Tooltip
content={restrictionInfo.isLocked ? restrictionInfo.lockReason : ''}
position='top'
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
>
<PopoverButton
tabIndex={0}
variant='ghost'
color='none'
popoverTarget={popoverId}
anchorName={popoverAnchorName}
className={restrictionInfo.isLocked ? 'text-error' : ''}
>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
</Tooltip>
<Icon icon='material-symbols:more-vert' width={16} height={16} />
</PopoverButton>
<PopoverContent
id={popoverId}
@@ -279,111 +218,80 @@ const RowOptionsMenu = ({
};
const RecordingTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
const {
state: tableFilterState,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = 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;
}>({
} = useTableFilter({
initial: {
search: '',
areaFilter: null,
locationFilter: null,
projectFlockFilter: null,
kandangFilter: null,
projectFlockKandangFilter: null,
approvalStatusFilter: null,
projectFlockCategoryFilter: null,
areaFilter: '',
locationFilter: '',
kandangFilter: '',
projectFlockKandangFilter: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
search: 'search',
areaFilter: 'area_id',
locationFilter: 'location_id',
projectFlockFilter: 'project_flock_id',
kandangFilter: 'kandang_id',
projectFlockKandangFilter: 'project_flock_kandang_id',
approvalStatusFilter: 'approval_status',
projectFlockCategoryFilter: 'project_flock_category',
},
persist: true,
storeName: 'recording-table',
});
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
// ===== FILTER MODAL STATE =====
const filterModal = useModal();
// ===== FILTER STATE =====
const [filterArea, setFilterArea] = useState<OptionType | null>(null);
const [filterLocation, setFilterLocation] = useState<OptionType | null>(null);
const [filterProjectFlock, setFilterProjectFlock] =
useState<OptionType | null>(null);
const [filterKandang, setFilterKandang] = useState<OptionType | null>(null);
const [, setFilterProjectFlockKandangId] = useState<number | undefined>(
undefined
);
const [filterLocationAreaId, setFilterLocationAreaId] = useState<string>('');
const [filterProjectFlockLocationId, setFilterProjectFlockLocationId] =
useState<string>('');
// ===== FORMIK SETUP =====
const formik = useFormik<RecordingFilterType>({
initialValues: {
area_id: tableFilterState.areaFilter,
location_id: tableFilterState.locationFilter,
project_flock_id: tableFilterState.projectFlockFilter,
kandang_id: tableFilterState.kandangFilter,
project_flock_kandang_id: tableFilterState.projectFlockKandangFilter,
approval_status: tableFilterState.approvalStatusFilter,
project_flock_category: tableFilterState.projectFlockCategoryFilter,
area_id: null,
location_id: null,
kandang_id: null,
project_flock_kandang_id: null,
},
validationSchema: RecordingFilterSchema,
onSubmit: (values, { setSubmitting }) => {
updateFilter('areaFilter', values.area_id, true);
updateFilter('locationFilter', values.location_id, true);
updateFilter('projectFlockFilter', values.project_flock_id, true);
updateFilter('kandangFilter', values.kandang_id, true);
updateFilter('areaFilter', values.area_id || '');
updateFilter('locationFilter', values.location_id || '');
updateFilter('kandangFilter', values.kandang_id || '');
updateFilter(
'projectFlockKandangFilter',
values.project_flock_kandang_id,
true
);
updateFilter('approvalStatusFilter', values.approval_status, true);
updateFilter(
'projectFlockCategoryFilter',
values.project_flock_category,
true
values.project_flock_kandang_id || ''
);
filterModal.closeModal();
setSubmitting(false);
},
onReset: () => {
updateFilter('areaFilter', '');
updateFilter('locationFilter', '');
updateFilter('kandangFilter', '');
updateFilter('projectFlockKandangFilter', '');
},
});
const formikResetHandler = () => {
updateFilter('areaFilter', null, true);
updateFilter('locationFilter', null, true);
updateFilter('projectFlockFilter', null, true);
updateFilter('kandangFilter', null, true);
updateFilter('projectFlockKandangFilter', null, true);
updateFilter('approvalStatusFilter', null, true);
updateFilter('projectFlockCategoryFilter', null, true);
formik.resetForm({
values: {
area_id: null,
location_id: null,
project_flock_id: null,
kandang_id: null,
project_flock_kandang_id: null,
approval_status: null,
project_flock_category: null,
},
});
filterModal.closeModal();
};
const { project_flock_id, kandang_id } = formik.values;
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const selectedRowIds = Object.keys(rowSelection).map((item) =>
@@ -397,16 +305,9 @@ const RecordingTable = () => {
const [isRejectLoading, setIsRejectLoading] = useState(false);
const [, setApprovalNotes] = useState('');
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
useState(false);
const [isExportProgressLoading, setIsExportProgressLoading] = useState(false);
const [exportProgressStartDate, setExportProgressStartDate] = useState('');
const [exportProgressEndDate, setExportProgressEndDate] = useState('');
const singleDeleteModal = useModal();
const approveModal = useModal();
const rejectModal = useModal();
const exportProgressInputModal = useModal();
const {
data: recordings,
@@ -418,6 +319,13 @@ const RecordingTable = () => {
);
// ===== LOCATION, AREA, KANDANG OPTIONS =====
const locationParams = useMemo(() => {
if (filterLocationAreaId) {
return { area_id: filterLocationAreaId };
}
return undefined;
}, [filterLocationAreaId]);
const {
setInputValue: setLocationInputValue,
options: locationOptions,
@@ -428,9 +336,7 @@ const RecordingTable = () => {
'id',
'name',
'search',
{
area_id: String(formik.values.area_id?.value),
}
locationParams
);
const {
@@ -445,6 +351,13 @@ const RecordingTable = () => {
'search'
);
const projectFlockParams = useMemo(() => {
if (filterProjectFlockLocationId) {
return { location_id: filterProjectFlockLocationId };
}
return undefined;
}, [filterProjectFlockLocationId]);
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
@@ -456,41 +369,34 @@ const RecordingTable = () => {
'id',
'flock_name',
'search',
{
location_id: String(formik.values.location_id?.value),
}
projectFlockParams
);
const kandangOptions = useMemo(() => {
if (!project_flock_id || !projectFlocksRawData) return [];
if (!filterProjectFlock || !projectFlocksRawData) return [];
if (!isResponseSuccess(projectFlocksRawData)) return [];
const data = projectFlocksRawData.data as ProjectFlock[];
const selectedProjectFlockData = data.find((pf) =>
pf.id === formik.values.project_flock_id?.value
? Number(formik.values.project_flock_id.value)
: 0
const selectedProjectFlockData = data.find(
(pf) => pf.id === filterProjectFlock.value
);
if (!selectedProjectFlockData?.kandangs) return [];
return selectedProjectFlockData.kandangs.map((k) => ({
value: k.id,
label: k.name || '',
}));
}, [project_flock_id, projectFlocksRawData]);
}, [filterProjectFlock, projectFlocksRawData]);
// ===== PROJECT FLOCK KANDANG LOOKUP =====
const projectFlockKandangLookupUrl = useMemo(() => {
if (!project_flock_id?.value || !kandang_id?.value) return null;
if (!filterProjectFlock || !filterKandang) return null;
const params = new URLSearchParams({
project_flock_id: project_flock_id.value.toString(),
kandang_id: kandang_id.value.toString(),
project_flock_id: filterProjectFlock.value.toString(),
kandang_id: filterKandang.value.toString(),
});
return `${ProjectFlockApi.basePath}/kandangs/lookup?${params.toString()}`;
}, [project_flock_id, kandang_id]);
}, [filterProjectFlock, filterKandang]);
const { data: projectFlockKandangLookupData } = useSWR(
projectFlockKandangLookupUrl,
@@ -512,45 +418,118 @@ const RecordingTable = () => {
? projectFlockKandangLookupData.data
: undefined;
const formikRef = useRef(formik);
useEffect(() => {
formikRef.current = formik;
});
useEffect(() => {
if (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 {
formik.setFieldValue('project_flock_kandang_id', null);
setFilterProjectFlockKandangId(undefined);
formikRef.current.setFieldValue('project_flock_kandang_id', null);
}
}, [projectFlockKandangLookup]);
// ===== FILTER HANDLERS =====
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('area_id', val);
formik.setFieldValue('location_id', null);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterAreaChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const area = val as OptionType | null;
const areaId = area?.value ? String(area.value) : null;
const handleFilterLocationChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('location_id', val);
formik.setFieldValue('project_flock_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
formik.setFieldValue('area_id', areaId);
formik.setFieldValue('location_id', null);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
const handleFilterProjectFlockChange = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('project_flock_id', val);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
};
setFilterArea(area);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId(areaId || '');
setFilterProjectFlockLocationId('');
},
[formik]
);
const handleFilterKandangChange = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('kandang_id', val);
formik.setFieldValue('project_flock_kandang_id', null);
};
const handleFilterLocationChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const location = val as OptionType | null;
const locationId = location?.value ? String(location.value) : null;
formik.setFieldValue('location_id', locationId);
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterLocation(location);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterProjectFlockLocationId(locationId || '');
},
[formik]
);
const handleFilterProjectFlockChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const projectFlock = val as OptionType | null;
formik.setFieldValue('kandang_id', null);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterProjectFlock(projectFlock);
setFilterKandang(null);
},
[formik]
);
const handleFilterKandangChange = useCallback(
(val: OptionType | OptionType[] | null) => {
const kandang = val as OptionType | null;
const kandangId = kandang?.value ? String(kandang.value) : null;
formik.setFieldValue('kandang_id', kandangId);
formik.setFieldValue('project_flock_kandang_id', null);
setFilterKandang(kandang);
},
[formik]
);
// ===== FILTER HELPERS =====
const areaIdValue = useMemo(() => {
if (!formik.values.area_id) return null;
return (
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
null
);
}, [formik.values.area_id, areaOptions]);
const locationIdValue = useMemo(() => {
if (!formik.values.location_id) return null;
return (
locationOptions.find(
(opt) => String(opt.value) === formik.values.location_id
) || null
);
}, [formik.values.location_id, locationOptions]);
const projectFlockIdValue = useMemo(() => {
if (!filterProjectFlock) return null;
return filterProjectFlock;
}, [filterProjectFlock]);
const kandangIdValue = useMemo(() => {
if (!formik.values.kandang_id) return null;
return (
kandangOptions.find(
(opt) => String(opt.value) === formik.values.kandang_id
) || null
);
}, [formik.values.kandang_id, kandangOptions]);
// ===== HANDLE FILTER MODAL OPEN =====
const handleFilterModalOpen = () => {
@@ -558,24 +537,35 @@ const RecordingTable = () => {
formik.validateForm();
};
const searchChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value, true);
};
const isRecordingApproved = useCallback((recording: Recording): boolean => {
return (
recording.approval?.action === 'APPROVED' &&
recording.approval?.step_name === 'Disetujui'
);
}, []);
useEffect(() => {
setTableState('recording-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter('search', e.target.value);
setSearchValue(e.target.value);
setPage(1);
},
[updateFilter, setSearchValue, setPage]
);
const singleDeleteHandler = async () => {
setIsDeleteLoading(true);
const response = await RecordingApi.delete(selectedRecording?.id as number);
await RecordingApi.delete(selectedRecording?.id as number);
refreshRecordings();
singleDeleteModal.closeModal();
toast.success('Successfully delete Recording!');
setIsDeleteLoading(false);
if (isResponseSuccess(response)) {
toast.success(response?.message || 'Successfully delete Recording!');
refreshRecordings();
} else {
toast.error(response?.message || 'Failed to delete Recording');
}
};
const approveHandler = async (notes: string) => {
@@ -644,68 +634,6 @@ const RecordingTable = () => {
});
}, [selectedRowIds, recordings, isRecordingApproved]);
const exportToExcelHandler = async () => {
setIsLoadingExportingToExcel(true);
await RecordingApi.exportToExcel(getTableFilterQueryString());
setIsLoadingExportingToExcel(false);
};
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressStartDate(e.target.value);
},
[]
);
const exportProgressEndDateChangeHandler = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setExportProgressEndDate(e.target.value);
},
[]
);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await RecordingApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
useEffect(() => {
if (isResponseSuccess(recordings) && recordings.data) {
const newSelection: Record<string, boolean> = {};
@@ -833,30 +761,11 @@ const RecordingTable = () => {
{
header: 'Kategori',
cell: (props) => {
const isTransition = props.row.original.is_transition;
const category =
props.row.original.project_flock?.project_flock_category ||
'GROWING';
props.row.original.project_flock?.project_flock_category;
if (!category) return '-';
const color = category === 'LAYING' ? 'info' : 'warning';
const isGrowingLocked =
category === 'GROWING' && props.row.original.is_laying;
return (
<div className='flex flex-col gap-1'>
<StatusBadge color={color} text={formatTitleCase(category)} />
{isTransition && (
<span className='text-xs text-warning font-medium'>
(Transisi)
</span>
)}
{isGrowingLocked && (
<span className='text-xs text-error font-medium'>
(Penguncian)
</span>
)}
</div>
);
return <StatusBadge color={color} text={formatTitleCase(category)} />;
},
},
{
@@ -866,8 +775,7 @@ const RecordingTable = () => {
<>
<span>
{props.row.original.day} (Minggu ke-
{props.row.original.week} hari ke-
{props.row.original.excess_days})
{props.row.original.project_flock.production_standart.week})
</span>
</>
);
@@ -1113,7 +1021,7 @@ const RecordingTable = () => {
return (
<div className='text-center'>
{value !== null && value !== undefined
? `${value.toFixed(2)} butir`
? `${value.toFixed(2)}%`
: '-'}
</div>
);
@@ -1129,7 +1037,7 @@ const RecordingTable = () => {
return (
<div className='text-center text-gray-600'>
{value !== null && value !== undefined
? `${value.toFixed(2)} btr`
? `${value.toFixed(2)}%`
: '-'}
</div>
);
@@ -1334,60 +1242,6 @@ const RecordingTable = () => {
onClick={handleFilterModalOpen}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
trigger={
<Button
variant='outline'
color='none'
className={cn(
'px-3 py-2.5 rounded-lg font-semibold text-sm gap-1.5',
'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft'
)}
>
<Icon
width={20}
height={20}
icon='heroicons:cloud-arrow-down'
/>
Export
<div className='w-6.5 h-5 flex items-center justify-center border-l border-base-content/10'>
<Icon
width={14}
height={14}
icon='heroicons:chevron-down'
/>
</div>
</Button>
}
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcelHandler}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Export to Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -1465,13 +1319,13 @@ const RecordingTable = () => {
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<div className='p-4 flex flex-col gap-1.5'>
<SelectInput
label='Area'
placeholder='Pilih Area'
options={areaOptions}
value={formik.values.area_id}
value={areaIdValue}
onChange={handleFilterAreaChange}
onInputChange={setAreaInputValue}
isLoading={isLoadingAreaOptions}
@@ -1484,13 +1338,13 @@ const RecordingTable = () => {
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.location_id}
value={locationIdValue}
onChange={handleFilterLocationChange}
onInputChange={setLocationInputValue}
isLoading={isLoadingLocationOptions}
isClearable
onMenuScrollToBottom={loadMoreLocations}
isDisabled={!formik.values.area_id?.value}
isDisabled={!filterArea}
className={{ wrapper: 'w-full' }}
/>
@@ -1498,13 +1352,13 @@ const RecordingTable = () => {
label='Project Flock'
placeholder='Pilih Project Flock'
options={projectFlockOptions}
value={formik.values.project_flock_id}
value={projectFlockIdValue}
onChange={handleFilterProjectFlockChange}
onInputChange={setProjectFlockInputValue}
isLoading={isLoadingProjectFlocks}
isClearable
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id?.value}
isDisabled={!filterLocation}
className={{ wrapper: 'w-full' }}
/>
@@ -1512,35 +1366,11 @@ const RecordingTable = () => {
label='Kandang'
placeholder='Pilih Kandang'
options={kandangOptions}
value={formik.values.kandang_id}
value={kandangIdValue}
onChange={handleFilterKandangChange}
isLoading={!formik.values.project_flock_id?.value}
isClearable
isDisabled={!formik.values.project_flock_id?.value}
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Kategori'
placeholder='Pilih Kategori'
options={projectFlockCategoryOptions}
value={formik.values.project_flock_category}
onChange={(val) => {
formik.setFieldValue('project_flock_category', val);
}}
isClearable
className={{ wrapper: 'w-full' }}
/>
<SelectInput
label='Status Approval'
placeholder='Pilih Status Approval'
options={recordingApprovalStatusOptions}
value={formik.values.approval_status}
onChange={(val) => {
formik.setFieldValue('approval_status', val);
}}
isLoading={!filterProjectFlock}
isClearable
isDisabled={!filterProjectFlock}
className={{ wrapper: 'w-full' }}
/>
</div>
@@ -1548,16 +1378,30 @@ const RecordingTable = () => {
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
type='button'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
onClick={() => {
formik.resetForm();
setFilterArea(null);
setFilterLocation(null);
setFilterProjectFlock(null);
setFilterKandang(null);
setFilterLocationAreaId('');
setFilterProjectFlockLocationId('');
filterModal.closeModal();
}}
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!formik.isValid || formik.isSubmitting}
disabled={
!formik.isValid ||
formik.isSubmitting ||
!formik.values.kandang_id
}
>
Apply Filter
</Button>
@@ -1580,76 +1424,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
ref={approveModal.ref}
type='success'
@@ -1,40 +1,15 @@
import { OptionType } from '@/components/input/SelectInput';
import * as Yup from 'yup';
import { string, object } from 'yup';
export const RecordingFilterSchema = Yup.object().shape({
area_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
location_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
kandang_id: Yup.object({
value: Yup.number().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_kandang_id: Yup.number().nullable(),
approval_status: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
project_flock_category: Yup.object({
value: Yup.string().nullable(),
label: Yup.string().nullable(),
}).nullable(),
export const RecordingFilterSchema = object().shape({
area_id: string().nullable(),
location_id: string().nullable(),
kandang_id: string().nullable(),
project_flock_kandang_id: string().nullable(),
});
export type RecordingFilterType = {
area_id: OptionType<number> | null;
location_id: OptionType<number> | null;
project_flock_id: OptionType<number> | null;
kandang_id: OptionType<number> | null;
project_flock_kandang_id: number | null;
approval_status: OptionType<string> | null;
project_flock_category: OptionType<string> | null;
area_id: string | null;
location_id: string | null;
kandang_id: string | null;
project_flock_kandang_id: string | null;
};
@@ -4,9 +4,7 @@ import {
CreateGrowingRecordingPayload,
CreateLayingRecordingPayload,
CreateEggPayload,
RecordingStock,
} from '@/types/api/production/recording';
import { getProductWarehouseOptionLabel } from '@/lib/product-warehouse';
type RecordingGrowingFormSchemaType = {
record_date: string;
@@ -31,101 +29,82 @@ type RecordingGrowingFormSchemaType = {
} | null;
project_flock_kandang_id: number;
stocks: {
product_warehouse_id:
| {
value: number;
label: string;
}
| undefined;
qty: number | string;
product_warehouse_id?: number;
qty?: number | string;
}[];
depletions: {
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
product_warehouse_id?: number;
qty?: number | string;
}[];
};
type RecordingLayingFormSchemaType = RecordingGrowingFormSchemaType & {
eggs: {
product_warehouse_id?: {
value: number;
label: string;
} | null;
product_warehouse_id?: number;
qty?: number | string;
weight?: number | string;
}[];
};
export type StockSchema = {
product_warehouse_id: {
value: number;
label: string;
};
product_warehouse_id: number;
qty: number | string;
};
export type DepletionSchema = {
product_warehouse_id?: {
value: number;
label: string;
} | null;
source_product_warehouse_id?: number;
product_warehouse_id?: number;
qty?: number | string;
};
export type EggSchema = {
product_warehouse_id?: {
value: number;
label: string;
} | null;
product_warehouse_id?: number;
qty?: number | string;
weight?: number | string;
};
const StockObjectSchema: Yup.ObjectSchema<StockSchema> = Yup.object({
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
product_warehouse_id: Yup.number()
.required('Produk wajib diisi!')
.typeError('Produk wajib diisi!'),
.min(1, 'Produk wajib diisi!')
.typeError('Produk harus berupa angka!'),
qty: Yup.number()
.required('Jumlah penggunaan wajib diisi!')
.moreThan(0, 'Jumlah penggunaan harus lebih dari 0!')
.min(1, 'Jumlah penggunaan tidak boleh 0!')
.typeError('Jumlah penggunaan harus berupa angka!'),
});
const OptionalStockObjectSchema: Yup.ObjectSchema<{
product_warehouse_id?: number;
qty?: number | string;
}> = Yup.object({
product_warehouse_id: Yup.number()
.optional()
.typeError('Produk harus berupa angka!'),
qty: Yup.number()
.optional()
.typeError('Jumlah penggunaan harus berupa angka!'),
});
const DepletionObjectSchema: Yup.ObjectSchema<DepletionSchema> = Yup.object({
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
product_warehouse_id: Yup.number()
.optional()
.nullable(),
source_product_warehouse_id: Yup.number()
.optional()
.typeError('Gudang sumber harus berupa angka!'),
.typeError('Depletions harus berupa angka!'),
qty: Yup.number()
.optional()
.typeError('Jumlah depletions harus berupa angka!'),
});
const EggObjectSchema: Yup.ObjectSchema<EggSchema> = Yup.object({
product_warehouse_id: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
product_warehouse_id: Yup.number()
.optional()
.nullable(),
.typeError('Kondisi telur harus berupa angka!'),
qty: Yup.number().optional().typeError('Jumlah telur harus berupa angka!'),
weight: Yup.number().optional().typeError('Berat telur harus berupa angka!'),
});
export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSchemaType> =
export const RecordingGrowingFormSchema = (
isTransitioningToLaying = false
): Yup.ObjectSchema<RecordingGrowingFormSchemaType> =>
Yup.object({
record_date: Yup.string()
.required('Tanggal recording wajib diisi!')
@@ -185,20 +164,24 @@ export const RecordingGrowingFormSchema: Yup.ObjectSchema<RecordingGrowingFormSc
return true;
}
),
stocks: Yup.array()
.of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
stocks: isTransitioningToLaying
? Yup.array().of(OptionalStockObjectSchema).default([])
: Yup.array()
.of(StockObjectSchema)
.min(1, 'Minimal harus ada 1 data stok!')
.required('Data stok wajib diisi!'),
depletions: Yup.array().of(DepletionObjectSchema).default([]),
});
export const RecordingLayingFormSchema: Yup.ObjectSchema<RecordingLayingFormSchemaType> =
RecordingGrowingFormSchema.shape({
RecordingGrowingFormSchema().shape({
eggs: Yup.array().of(EggObjectSchema).default([]),
});
export const UpdateRecordingGrowingFormSchema =
RecordingGrowingFormSchema.shape({
export const UpdateRecordingGrowingFormSchema = (
isTransitioningToLaying = false
) =>
RecordingGrowingFormSchema(isTransitioningToLaying).shape({
location_id: Yup.number().nullable().optional(),
project_flock_id: Yup.number().nullable().optional(),
kandang_id: Yup.number().nullable().optional(),
@@ -228,10 +211,13 @@ export const UpdateRecordingLayingFormSchema = RecordingLayingFormSchema.shape({
.required('Project Flock Kandang wajib diisi!'),
});
export type RecordingGrowingFormValues = Yup.InferType<
type RecordingGrowingFormSchemaFn = ReturnType<
typeof RecordingGrowingFormSchema
>;
export type RecordingGrowingFormValues =
Yup.InferType<RecordingGrowingFormSchemaFn>;
export type RecordingLayingFormValues = Yup.InferType<
typeof RecordingLayingFormSchema
>;
@@ -278,18 +264,14 @@ export const getRecordingGrowingFormInitialValues = (
initialValues?.project_flock?.project_flock_kandang_id ??
0,
stocks: initialValues?.stocks?.map((stock) => ({
product_warehouse_id: {
value: stock.product_warehouse_id,
label: getProductWarehouseOptionLabel(stock.product_warehouse),
},
product_warehouse_id: stock.product_warehouse_id,
qty:
(stock as RecordingStock).qty ||
((stock as RecordingStock).usage_amount || 0) +
((stock as RecordingStock).pending_qty || 0) ||
(stock as { qty?: number; usage_amount?: number }).qty ||
(stock as { qty?: number; usage_amount?: number }).usage_amount ||
'',
})) ?? [
{
product_warehouse_id: undefined,
product_warehouse_id: 0,
qty: '',
},
],
@@ -297,16 +279,12 @@ export const getRecordingGrowingFormInitialValues = (
(
depletion: NonNullable<CreateGrowingRecordingPayload['depletions']>[0]
) => ({
product_warehouse_id: {
value: Number(depletion.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(depletion.product_warehouse),
},
source_product_warehouse_id: depletion.source_product_warehouse_id,
product_warehouse_id: depletion.product_warehouse_id,
qty: depletion.qty,
})
) ?? [
{
product_warehouse_id: undefined,
product_warehouse_id: 0,
qty: '',
},
],
@@ -318,15 +296,12 @@ export const getRecordingLayingFormInitialValues = (
...getRecordingGrowingFormInitialValues(initialValues),
eggs: initialValues?.eggs?.map((egg: CreateEggPayload) => ({
product_warehouse_id: {
value: Number(egg.product_warehouse_id ?? 0),
label: getProductWarehouseOptionLabel(egg.product_warehouse),
},
product_warehouse_id: egg.product_warehouse_id,
qty: egg.qty,
weight: egg.weight,
})) ?? [
{
product_warehouse_id: null,
product_warehouse_id: 0,
qty: '',
weight: '',
},
File diff suppressed because it is too large Load Diff
@@ -1,73 +0,0 @@
export type RecordingRestriction = {
canEditStock: boolean;
canEditDepletion: boolean;
canEditEgg: boolean;
isLocked: boolean;
lockReason?: string;
};
export const getRecordingRestriction = (
isLaying: boolean,
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
@@ -40,9 +40,6 @@ const TransferToLayingDetailModal = () => {
? transferToLayingResponse.data
: undefined;
const isTransferToLayingApproved =
transferToLaying?.approval.step_number === 2;
const { data: transferToLayingApprovalResponse } = useSWR(
transferToLayingId
? ['approval-transfer-to-laying', transferToLayingId]
@@ -58,9 +55,9 @@ const TransferToLayingDetailModal = () => {
const detailModal = useModal();
const maxSourceQuantity =
const totalEnteredChickenForTransfer =
transferToLaying?.sources.reduce(
(acc, item) => acc + Number(item.product_warehouse.quantity),
(acc, item) => acc + Number(item.qty),
0
) ?? 0;
@@ -70,9 +67,8 @@ const TransferToLayingDetailModal = () => {
0
) ?? 0;
// Sisa transfer = Max available dari kandang asal - Total yang sudah diisi di kandang tujuan
const totalAvailableChickenForTransfer =
maxSourceQuantity - totalTransferedChicken;
totalEnteredChickenForTransfer - totalTransferedChicken;
const closeModalHandler = (shouldPushToRoute: boolean = true) => {
if (shouldPushToRoute) {
@@ -165,34 +161,11 @@ const TransferToLayingDetailModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
{!isTransferToLayingApproved && (
<>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</>
)}
</span>
{transferToLaying?.sources.length === 0 && (
@@ -252,6 +225,21 @@ const TransferToLayingDetailModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0 ? 'error' : 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{transferToLaying?.targets.length === 0 && (
@@ -316,7 +304,7 @@ const TransferToLayingDetailModal = () => {
readOnly
errorMessage={
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 { ProjectFlockApi } from '@/services/api/production';
import { Flock } from '@/types/api/master-data/flock';
import { TransferToLayingFilter } from '@/types/api/production/transfer-to-laying';
import {
TransferToLayingFilterSchema,
TransferToLayingFilterValues,
@@ -20,14 +21,12 @@ import {
interface TransferToLayingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: Partial<TransferToLayingFilterValues>;
onSubmit?: (values: TransferToLayingFilterValues) => void;
onSubmit?: (values: TransferToLayingFilter) => void;
onReset?: () => void;
}
const TransferToLayingFilterModal = ({
ref,
initialValues: initialValuesProp,
onSubmit,
onReset,
}: TransferToLayingFilterModal) => {
@@ -87,16 +86,28 @@ const TransferToLayingFilterModal = ({
const formik = useFormik<TransferToLayingFilterValues>({
initialValues: {
startDate: initialValuesProp?.startDate ?? '',
endDate: initialValuesProp?.endDate ?? '',
flockSource: initialValuesProp?.flockSource ?? [],
flockDestination: initialValuesProp?.flockDestination ?? [],
status: initialValuesProp?.status ?? [],
startDate: '',
endDate: '',
flockSource: [],
flockDestination: [],
status: [],
},
enableReinitialize: true,
validationSchema: TransferToLayingFilterSchema,
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();
},
onReset: () => {
@@ -223,8 +223,6 @@ const TransferToLayingFormModal = () => {
},
});
const { flockSource: formikFlockSource } = formik.values;
const { formErrorList, close, handleFormSubmit } = useFormikErrorList(formik);
const [selectedFlockSourceRawData, setSelectedFlockSourceRawData] = useState<
@@ -457,13 +455,13 @@ const TransferToLayingFormModal = () => {
useEffect(() => {
if (isResponseSuccess(flockSourceRawData)) {
const currentSelectedFlockSourceRawData = flockSourceRawData.data.find(
const selectedFlockSourceRawData = flockSourceRawData.data.find(
(item) => item.id === formik.values.flockSource?.value
);
setSelectedFlockSourceRawData(currentSelectedFlockSourceRawData);
setSelectedFlockSourceRawData(selectedFlockSourceRawData);
}
}, [flockSourceRawData, formikFlockSource]);
}, [flockSourceRawData]);
useEffect(() => {
formik.setFieldValue('totalQuantity', totalTransferedChicken);
@@ -627,7 +625,6 @@ const TransferToLayingFormModal = () => {
>
<div className='flex flex-row items-center gap-3'>
<input
id={`flock-source-kandang-${item.project_flock_kandang_id}`}
type='radio'
name='flockSourceKandang'
value={item.project_flock_kandang_id}
@@ -640,14 +637,13 @@ const TransferToLayingFormModal = () => {
/>
<label
htmlFor={`flock-source-kandang-${item.project_flock_kandang_id}`}
className={cn('text-sm text-base-content/50', {
'cursor-pointer': isAvailable,
'cursor-not-allowed opacity-50': !isAvailable,
})}
>
{item.kandang_name}{' '}
<span className='text-base-content/20'>{`(Max: ${item.available_qty ?? '-'})`}</span>
<span className='text-base-content/20'>{`(Max: ${item.available_qty})`}</span>
</label>
</div>
@@ -822,33 +818,11 @@ const TransferToLayingFormModal = () => {
{/* Source Kandang */}
<div className='flex flex-col'>
<span className='w-fit py-2 text-xs font-semibold flex flex-row items-center gap-3'>
<span className='text-nowrap'>
Kandang Asal{' '}
<span
className='tooltip tooltip-error'
data-tip='required'
>
<span className='text-error'> *</span>
</span>
<span className='w-full py-2 text-xs font-semibold'>
Kandang Asal{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa ayam: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockSourceKandangs.length === 0 && (
@@ -928,6 +902,23 @@ const TransferToLayingFormModal = () => {
<span className='text-error'> *</span>
</span>
</span>
<div className='w-px h-5 bg-base-content/10' />
<StatusBadge
color={
totalAvailableChickenForTransfer < 0
? 'error'
: 'neutral'
}
text={`Sisa transfer: ${formatNumber(
totalAvailableChickenForTransfer,
'en-US'
)} ekor`}
className={{
badge: 'text-nowrap',
}}
/>
</span>
{formik.values.flockDestinationKandangs.length === 0 && (
@@ -1,6 +1,6 @@
'use client';
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
import { ChangeEventHandler, useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useUiStore } from '@/stores/ui/ui.store';
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 TransferToLayingTableSkeleton from '@/components/pages/production/transfer-to-laying/skeleton/TransferToLayingTableSkeleton';
import { TransferToLaying } from '@/types/api/production/transfer-to-laying';
import { TransferToLayingFilterValues } from '@/components/pages/production/transfer-to-laying/filter/TransferToLayingFilter';
import { OptionType } from '@/components/input/SelectInput';
import {
TransferToLaying,
TransferToLayingFilter,
} from '@/types/api/production/transfer-to-laying';
import { TransferToLayingApi } from '@/services/api/production/transfer-to-laying';
import { cn, formatDate, formatNumber } from '@/lib/helper';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -141,8 +142,6 @@ const TransferToLayingsTable = () => {
status: '',
filter_by: '',
sort_by: '',
flockSourceNames: '',
flockDestinationNames: '',
},
paramMap: {
page: 'page',
@@ -155,9 +154,6 @@ const TransferToLayingsTable = () => {
filter_by: 'filter_by',
sort_by: 'sort_by',
},
excludeKeysFromUrl: ['flockSourceNames', 'flockDestinationNames'],
persist: true,
storeName: 'transfer-to-laying-table',
});
const {
@@ -435,84 +431,12 @@ const TransferToLayingsTable = () => {
updateFilter('search', e.target.value);
};
const STATUS_FILTER_OPTIONS = [
{ value: 'PENDING', label: 'Pengajuan' },
{ value: 'APPROVED', label: 'Disetujui' },
{ value: 'REJECTED', label: 'Ditolak' },
];
const filterModalInitialValues = useMemo(() => {
const flockSourceIds = tableFilterState.flockSource
? tableFilterState.flockSource.split(',')
: [];
const flockSourceNameList = tableFilterState.flockSourceNames
? tableFilterState.flockSourceNames.split(',')
: [];
const flockSourceOptions = flockSourceIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockSourceNameList[i] || id,
}));
const flockDestIds = tableFilterState.flockDestination
? tableFilterState.flockDestination.split(',')
: [];
const flockDestNameList = tableFilterState.flockDestinationNames
? tableFilterState.flockDestinationNames.split(',')
: [];
const flockDestOptions = flockDestIds.filter(Boolean).map((id, i) => ({
value: parseInt(id),
label: flockDestNameList[i] || id,
}));
const statusIds = tableFilterState.status
? tableFilterState.status.split(',')
: [];
const statusOptions = statusIds.filter(Boolean).map((id) => {
const found = STATUS_FILTER_OPTIONS.find((opt) => opt.value === id);
return found || { value: id, label: id };
});
return {
startDate: tableFilterState.startDate || '',
endDate: tableFilterState.endDate || '',
flockSource: flockSourceOptions,
flockDestination: flockDestOptions,
status: statusOptions,
};
}, [
tableFilterState.startDate,
tableFilterState.endDate,
tableFilterState.flockSource,
tableFilterState.flockDestination,
tableFilterState.status,
tableFilterState.flockSourceNames,
tableFilterState.flockDestinationNames,
]);
const filterSubmitHandler = (values: TransferToLayingFilterValues) => {
const flockSourceOpts = (values.flockSource as OptionType[]) || [];
const flockDestOpts = (values.flockDestination as OptionType[]) || [];
const statusOpts = (values.status as OptionType[]) || [];
updateFilter('startDate', values.startDate || '');
updateFilter('endDate', values.endDate || '');
updateFilter(
'flockSource',
flockSourceOpts.map((o) => String(o.value)).join(',')
);
updateFilter(
'flockDestination',
flockDestOpts.map((o) => String(o.value)).join(',')
);
updateFilter('status', statusOpts.map((o) => String(o.value)).join(','));
updateFilter(
'flockSourceNames',
flockSourceOpts.map((o) => String(o.label)).join(',')
);
updateFilter(
'flockDestinationNames',
flockDestOpts.map((o) => String(o.label)).join(',')
);
const filterSubmitHandler = (values: TransferToLayingFilter) => {
updateFilter('startDate', values.startDate);
updateFilter('endDate', values.endDate);
updateFilter('flockSource', values.flockSource.join(','));
updateFilter('flockDestination', values.flockDestination.join(','));
updateFilter('status', values.status.join(','));
};
const filterResetHandler = () => {
@@ -521,8 +445,6 @@ const TransferToLayingsTable = () => {
updateFilter('flockSource', '');
updateFilter('flockDestination', '');
updateFilter('status', '');
updateFilter('flockSourceNames', '');
updateFilter('flockDestinationNames', '');
};
const exportToExcelHandler = async () => {
@@ -636,8 +558,6 @@ const TransferToLayingsTable = () => {
'search',
'filter_by',
'sort_by',
'flockSourceNames',
'flockDestinationNames',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
@@ -750,7 +670,6 @@ const TransferToLayingsTable = () => {
<TransferToLayingFilterModal
ref={filterModal.ref}
initialValues={filterModalInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
@@ -3,6 +3,7 @@ import { Uniformity } from '@/types/api/production/uniformity';
type UniformityFormSchemaType = {
date: string;
week: number;
location?: {
value: number;
label: string;
@@ -44,6 +45,10 @@ const FileSchema = Yup.mixed<File>()
export const UniformityFormSchema: Yup.ObjectSchema<UniformityFormSchemaType> =
Yup.object({
date: Yup.string().required('Tanggal wajib diisi!'),
week: Yup.number()
.min(1, 'Minggu ke wajib diisi!')
.required('Minggu ke wajib diisi!')
.typeError('Minggu ke wajib diisi!'),
location: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
@@ -76,6 +81,7 @@ export type UniformityFormValues = Yup.InferType<typeof UniformityFormSchema>;
export type UniformityFormData = {
date: string;
week: number;
project_flock_kandang_id: number;
document: File | null;
document_name: string;
@@ -85,7 +91,8 @@ export const getUniformityFormInitialValues = (
initialValues?: Partial<Uniformity>
): UniformityFormValues => {
return {
date: '',
date: initialValues?.week ? '' : '',
week: initialValues?.week ?? 0,
location: null,
location_id: 0,
project_flock: null,
@@ -27,6 +27,7 @@ import { LocationApi } from '@/services/api/master-data';
import {
ProjectFlockApi,
ProjectFlockKandangApi,
RecordingApi,
} from '@/services/api/production';
import { UniformityApi } from '@/services/api/uniformity';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
@@ -39,6 +40,7 @@ import {
ProjectFlockKandangLookup,
ProjectFlock,
} from '@/types/api/production/project-flock';
import { Recording } from '@/types/api/production/recording';
import { Kandang } from '@/types/api/master-data/kandang';
import UniformityPreviewForm from '@/components/pages/production/uniformity/form/UniformityPreviewForm';
import UniformityResultForm from '@/components/pages/production/uniformity/form/UniformityResultForm';
@@ -202,6 +204,23 @@ const UniformityForm = ({
? projectFlockKandangLookupData.data
: undefined;
// ===== RECORDINGS DATA (FOR WEEK CALCULATION) =====
const recordingsUrl = useMemo(() => {
if (!projectFlockKandangLookup?.project_flock_kandang_id) return null;
const params = new URLSearchParams({
page: '1',
limit: '100',
project_flock_kandang_id:
projectFlockKandangLookup.project_flock_kandang_id.toString(),
});
return `${RecordingApi.basePath}?${params.toString()}`;
}, [projectFlockKandangLookup?.project_flock_kandang_id]);
const { data: recordingsData } = useSWR(
recordingsUrl,
recordingsUrl ? RecordingApi.getAllFetcher : null
);
// ===== FORM CONFIGURATION =====
const formikInitialValues = useMemo<UniformityFormValues>(
() => getUniformityFormInitialValues(initialValues),
@@ -227,6 +246,7 @@ const UniformityForm = ({
setUniformityFormData({
date: values.date,
week: values.week,
project_flock_kandang_id: projectFlockKandangId,
document: values.document as File,
document_name: (values.document as File).name,
@@ -455,6 +475,59 @@ const UniformityForm = ({
generateUniformityTemplate(population, projectFlockKandangLookup);
}, [projectFlockKandangLookup]);
// ===== SIDE EFFECTS =====
useEffect(() => {
if (
projectFlockKandangLookup?.chick_in_date &&
projectFlockKandangLookup?.project_flock_kandang_id
) {
const chickInDate = new Date(projectFlockKandangLookup.chick_in_date);
chickInDate.setHours(0, 0, 0, 0);
let initialWeek = 18;
if (
isResponseSuccess(recordingsData) &&
recordingsData.data &&
recordingsData.data.length > 0
) {
const sortedRecordings = [...recordingsData.data].sort(
(a: Recording, b: Recording) =>
new Date(a.record_datetime).getTime() -
new Date(b.record_datetime).getTime()
);
const earliestRecording = sortedRecordings[0];
if (earliestRecording?.project_flock?.production_standart?.week) {
initialWeek =
earliestRecording.project_flock.production_standart.week;
}
}
if (formik.values.date) {
const selectedDate = new Date(formik.values.date);
selectedDate.setHours(0, 0, 0, 0);
const daysDiff = Math.floor(
(selectedDate.getTime() - chickInDate.getTime()) /
(1000 * 60 * 60 * 24)
);
const weeksDiff = Math.floor(daysDiff / 7);
setFieldValue('week', initialWeek + weeksDiff);
} else {
setFieldValue('week', initialWeek);
}
}
}, [
projectFlockKandangLookup?.chick_in_date,
projectFlockKandangLookup?.project_flock_kandang_id,
recordingsData,
formik.values.date,
setFieldValue,
]);
useEffect(() => {
const unsub = subscribeValidate(() => {
setIsValid(true);
@@ -524,7 +597,6 @@ const UniformityForm = ({
onBlur={formik.handleBlur}
isError={formik.touched.date && Boolean(formik.errors.date)}
errorMessage={formik.errors.date as string}
disabled={isNextStep}
/>
<SelectInput
@@ -543,7 +615,6 @@ const UniformityForm = ({
errorMessage={formik.errors.location_id as string}
isClearable
className={{ wrapper: 'w-full' }}
isDisabled={isNextStep}
/>
<SelectInput
@@ -556,7 +627,7 @@ const UniformityForm = ({
onInputChange={setProjectFlockSearchValue}
isLoading={isLoadingProjectFlocks}
onMenuScrollToBottom={loadMoreProjectFlocks}
isDisabled={!formik.values.location_id || isNextStep}
isDisabled={!formik.values.location_id}
isError={
formik.touched.project_flock_id &&
Boolean(formik.errors.project_flock_id)
@@ -573,7 +644,7 @@ const UniformityForm = ({
value={formik.values.kandang}
onChange={handleKandangChange}
options={kandangOptions}
isDisabled={!formik.values.project_flock_id || isNextStep}
isDisabled={!formik.values.project_flock_id}
isError={
formik.touched.kandang_id && Boolean(formik.errors.kandang_id)
}
@@ -63,6 +63,7 @@ const UniformityResultForm = () => {
try {
const payload = {
date: uniformityFormData.date,
week: uniformityFormData.week,
project_flock_kandang_id: uniformityFormData.project_flock_kandang_id,
document: uniformityFormData.document,
};
@@ -1,449 +0,0 @@
'use client';
import { RefObject, useState, useEffect, useMemo, useCallback } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import SelectInput from '@/components/input/SelectInput';
import { OptionType, useSelect } from '@/components/input/SelectInput';
import { PurchaseFilter } from '@/types/api/purchase/purchase';
import { AreaApi, LocationApi, SupplierApi } from '@/services/api/master-data';
import { ProductCategory } from '@/types/api/master-data/product-category';
import { ProductCategoryApi } from '@/services/api/master-data';
import { Area } from '@/types/api/master-data/area';
import { Location } from '@/types/api/master-data/location';
import { Supplier } from '@/types/api/master-data/supplier';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { isResponseSuccess } from '@/lib/api-helper';
interface PurchaseFilterModalProps {
ref: RefObject<HTMLDialogElement | null>;
initialValues?: {
poDate: string;
category: OptionType<number>[];
status: OptionType<string>[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
};
onSubmit?: (values: PurchaseFilter) => void;
onReset?: () => void;
}
const PurchaseFilterModal = ({
ref,
initialValues,
onSubmit,
onReset,
}: PurchaseFilterModalProps) => {
const closeModalHandler = useCallback(() => {
ref.current?.close();
}, [ref]);
// ===== DATE ERROR STATE =====
const [dateErrorShown, setDateErrorShown] = useState(false);
// ===== CLEANUP TOAST ON UNMOUNT =====
useEffect(() => {
return () => {
if (dateErrorShown) {
toast.dismiss();
}
};
}, [dateErrorShown]);
// ===== CLEANUP TOAST WHEN MODAL CLOSES =====
useEffect(() => {
const dialogElement = ref.current;
const handleModalClose = () => {
if (dateErrorShown) {
toast.dismiss();
setDateErrorShown(false);
}
};
dialogElement?.addEventListener('close', handleModalClose);
return () => {
dialogElement?.removeEventListener('close', handleModalClose);
};
}, [ref, dateErrorShown]);
const {
setInputValue: setProductCategoryInputValue,
options: productCategoryOptions,
isLoadingOptions: isLoadingProductCategoryOptions,
loadMore: loadMoreProductCategory,
} = useSelect<ProductCategory>(
ProductCategoryApi.basePath,
'id',
'name',
'search'
);
const [selectedAreaId, setSelectedAreaId] = useState(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
const [selectedLocationId, setSelectedLocationId] = useState(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
const {
setInputValue: setSupplierInputValue,
options: supplierOptions,
isLoadingOptions: isLoadingSupplierOptions,
loadMore: loadMoreSuppliers,
} = useSelect<Supplier>(SupplierApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setAreaInputValue,
options: areaOptions,
isLoadingOptions: isLoadingAreaOptions,
loadMore: loadMoreAreas,
} = useSelect<Area>(AreaApi.basePath, 'id', 'name', 'search');
const {
setInputValue: setLocationInputValue,
options: locationOptions,
isLoadingOptions: isLoadingLocationOptions,
loadMore: loadMoreLocations,
} = useSelect<Location>(LocationApi.basePath, 'id', 'name', 'search', {
area_id: selectedAreaId || '',
});
const {
setInputValue: setProjectFlockInputValue,
options: projectFlockOptions,
rawData: projectFlocksRawData,
isLoadingOptions: isLoadingProjectFlockOptions,
loadMore: loadMoreProjectFlocks,
} = useSelect<ProjectFlock>(
ProjectFlockApi.basePath,
'id',
'flock_name',
'search',
{
location_id: selectedLocationId || '',
}
);
const formik = useFormik<{
poDate: string;
category: { label: string; value: number }[];
status: { label: string; value: string }[];
supplier: OptionType<number> | null;
area: OptionType<number> | null;
location: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
}>({
// enableReinitialize: true,
initialValues: initialValues || {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
onSubmit: async (values) => {
const formattedValues = {
...values,
category: values.category.map((item) => String(item.value)),
category_labels: values.category,
status: values.status.map((item) => String(item.value)),
supplier_id: values.supplier?.value,
supplier_label: values.supplier?.label,
area_id: values.area?.value,
area_label: values.area?.label,
location_id: values.location?.value,
location_label: values.location?.label,
project_flock_id: values.project_flock?.value,
project_flock_label: values.project_flock?.label,
project_flock_kandang_id: values.project_flock_kandang?.value,
project_flock_kandang_label: values.project_flock_kandang?.label,
};
onSubmit?.(formattedValues);
closeModalHandler();
},
onReset: () => {
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
},
});
const { resetForm, submitForm } = formik;
useEffect(() => {
setSelectedAreaId(
initialValues?.area?.value ? String(initialValues.area.value) : ''
);
setSelectedLocationId(
initialValues?.location?.value ? String(initialValues.location.value) : ''
);
}, [initialValues?.area, initialValues?.location]);
const projectFlockKandangOptions = useMemo(() => {
if (
!formik.values.project_flock ||
!projectFlocksRawData ||
!isResponseSuccess(projectFlocksRawData)
) {
return [];
}
const selectedProjectFlock = projectFlocksRawData.data.find(
(item) => item.id === formik.values.project_flock?.value
);
return (
selectedProjectFlock?.kandangs?.map((item) => ({
value: item.project_flock_kandang_id,
label: item.name,
})) || []
);
}, [formik.values.project_flock, projectFlocksRawData]);
const productCategoryChangeHandler = (
val: OptionType | OptionType[] | null
) => {
formik.setFieldValue('category', val);
};
const statusChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('status', val);
};
const formikResetHandler = useCallback(() => {
resetForm({
values: {
poDate: '',
category: [],
status: [],
supplier: null,
area: null,
location: null,
project_flock: null,
project_flock_kandang: null,
},
});
setSelectedAreaId('');
setSelectedLocationId('');
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const formikSubmitHandler = useCallback(async () => {
await submitForm();
}, [submitForm]);
return (
<Modal
ref={ref}
className={{
modalBox: 'p-0 rounded-xl',
}}
>
<form
onSubmit={formik.handleSubmit}
onReset={formikResetHandler}
className='w-full flex flex-col'
>
{/* Modal Header */}
<div className='p-4 flex items-center justify-between gap-2 border-b border-gray-300'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='text-sm font-medium'>Filter Data</h3>
</div>
<Button
type='button'
variant='ghost'
color='none'
onClick={() => {
closeModalHandler();
}}
className='p-0 text-base-content/50 hover:text-base-content'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div className='flex flex-col'>
<DateInput
label='PO Date'
name='poDate'
placeholder='Pilih Tanggal'
value={formik.values.poDate}
onChange={formik.handleChange}
isNestedModal
/>
<SelectInputCheckbox
label='Kategori'
placeholder='Pilih Kategori'
value={formik.values.category}
onChange={productCategoryChangeHandler}
options={productCategoryOptions}
isLoading={isLoadingProductCategoryOptions}
onInputChange={setProductCategoryInputValue}
onMenuScrollToBottom={loadMoreProductCategory}
/>
<SelectInputCheckbox
label='Status'
placeholder='Status'
value={formik.values.status}
onChange={statusChangeHandler}
options={PURCHASE_ORDER_APPROVAL_LINE.map((item) => ({
label: item.step_name,
value: item.step_name,
}))}
/>
<SelectInput
label='Vendor'
placeholder='Pilih Vendor'
value={formik.values.supplier}
onChange={(val) =>
formik.setFieldValue(
'supplier',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={supplierOptions}
isLoading={isLoadingSupplierOptions}
onInputChange={setSupplierInputValue}
onMenuScrollToBottom={loadMoreSuppliers}
isClearable
/>
<SelectInput
label='Area'
placeholder='Pilih Area'
value={formik.values.area}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('area', nextValue);
formik.setFieldValue('location', null);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedAreaId(
nextValue?.value ? String(nextValue.value) : ''
);
setSelectedLocationId('');
}}
options={areaOptions}
isLoading={isLoadingAreaOptions}
onInputChange={setAreaInputValue}
onMenuScrollToBottom={loadMoreAreas}
isClearable
/>
<SelectInput
label='Lokasi'
placeholder='Pilih Lokasi'
value={formik.values.location}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('location', nextValue);
formik.setFieldValue('project_flock', null);
formik.setFieldValue('project_flock_kandang', null);
setSelectedLocationId(
nextValue?.value ? String(nextValue.value) : ''
);
}}
options={locationOptions}
isLoading={isLoadingLocationOptions}
onInputChange={setLocationInputValue}
onMenuScrollToBottom={loadMoreLocations}
isClearable
isDisabled={!formik.values.area}
/>
<SelectInput
label='Project Flock'
placeholder='Pilih Project Flock'
value={formik.values.project_flock}
onChange={(val) => {
const nextValue = !Array.isArray(val)
? (val as OptionType<number> | null)
: null;
formik.setFieldValue('project_flock', nextValue);
formik.setFieldValue('project_flock_kandang', null);
}}
options={projectFlockOptions}
isLoading={isLoadingProjectFlockOptions}
onInputChange={setProjectFlockInputValue}
onMenuScrollToBottom={loadMoreProjectFlocks}
isClearable
isDisabled={!formik.values.location}
/>
<SelectInput
label='Kandang'
placeholder='Pilih Kandang'
value={formik.values.project_flock_kandang}
onChange={(val) =>
formik.setFieldValue(
'project_flock_kandang',
!Array.isArray(val)
? (val as OptionType<number> | null)
: null
)
}
options={projectFlockKandangOptions}
isClearable
isDisabled={!formik.values.project_flock}
/>
</div>
</div>
{/* Modal Footer */}
<div className='p-4 flex justify-between gap-4 border-t border-gray-300 bg-gray-100'>
<Button
type='reset'
variant='ghost'
color='none'
className='p-3 rounded-lg text-base-content/65'
>
Reset Filter
</Button>
<Button
type='button'
onClick={formikSubmitHandler}
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
>
Apply Filter
</Button>
</div>
</form>
</Modal>
);
};
export default PurchaseFilterModal;
+83 -430
View File
@@ -1,54 +1,42 @@
'use client';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import {
ChangeEventHandler,
useCallback,
useEffect,
useMemo,
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 Link from 'next/link';
import { Icon } from '@iconify/react';
import Table from '@/components/Table';
import DebouncedTextInput from '@/components/input/DebouncedTextInput';
import DateInput from '@/components/input/DateInput';
import Button from '@/components/Button';
import Modal, { useModal } from '@/components/Modal';
import { useModal } from '@/components/Modal';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
import PopoverButton from '@/components/popover/PopoverButton';
import PopoverContent from '@/components/popover/PopoverContent';
import RequirePermission from '@/components/helper/RequirePermission';
import StatusBadge from '@/components/helper/StatusBadge';
import PurchaseTableSkeleton from '@/components/pages/purchase/skeleton/PurchaseTableSkeleton';
import ButtonFilter from '@/components/helper/ButtonFilter';
import PurchaseFilterModal from '@/components/pages/purchase/PurchaseFilterModal';
import Dropdown from '@/components/dropdown/Dropdown';
import { OptionType } from '@/components/input/SelectInput';
import { cn, formatDate } from '@/lib/helper';
import { 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 { Purchase, PurchaseFilter } from '@/types/api/purchase/purchase';
import { Purchase } from '@/types/api/purchase/purchase';
import { PurchaseApi } from '@/services/api/purchase';
import { ExpenseApi } from '@/services/api/expense';
import { Expense } from '@/types/api/expense';
import { Color } from '@/types/theme';
import { PURCHASE_ORDER_APPROVAL_LINE } from '@/config/approval-line';
type PurchaseTableFilters = {
search: string;
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;
};
import Link from 'next/link';
// ===== STATUS BADGE UTILITIES =====
const statusTextMap: Record<string, string> = {
@@ -157,72 +145,35 @@ const RowOptionsMenu = ({
};
const PurchaseTable = () => {
const { searchValue, setSearchValue, setTableState } = useUiStore();
const pathname = usePathname();
// ===== 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, setSorting] = useState<SortingState>([]);
// ===== TABLE FILTER STATE =====
const {
state: tableFilterState,
setFilters,
updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString,
} = useTableFilter<PurchaseTableFilters>({
} = useTableFilter({
initial: {
search: '',
po_date: '',
approval_status: '',
product_category_id: '',
product_category_name: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
},
paramMap: {
page: 'page',
pageSize: 'limit',
po_date: 'po_date',
approval_status: 'approval_status',
product_category_id: 'product_category_id',
supplier_id: 'supplier_id',
area_id: 'area_id',
location_id: 'location_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
},
excludeKeysFromUrl: [
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
],
persist: true,
storeName: 'purchase-table',
});
// ===== MODAL HOOKS =====
const filterModal = useModal();
const deleteModal = useModal();
const exportProgressInputModal = useModal();
// ===== API DATA FETCHING =====
const {
@@ -234,6 +185,33 @@ const PurchaseTable = () => {
PurchaseApi.getAllFetcher
);
const getKey = (
pageIndex: number,
previousPageData: BaseApiResponse<Expense>[] | null
) => {
if (pageIndex > 0 && !previousPageData) return null;
return `${ExpenseApi.basePath}?page=${pageIndex + 1}&limit=100`;
};
const { data: expensesPages } = useSWRInfinite(
getKey,
ExpenseApi.getAllFetcher
);
const expenseMap = useMemo(() => {
const map = new Map<string, number>();
if (!expensesPages) return map;
expensesPages.forEach((page) => {
if (isResponseSuccess(page)) {
page.data.forEach((expense: Expense) => {
map.set(expense.reference_number, expense.id);
});
}
});
return map;
}, [expensesPages]);
// ===== TABLE COLUMNS DEFINITION =====
const purchaseColumns: ColumnDef<Purchase>[] = [
{
@@ -252,16 +230,20 @@ const PurchaseTable = () => {
return (
<ul className='list-disc pl-4'>
{poExpedition.map((exp, index) => {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${exp.id}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
const expenseId = expenseMap.get(exp.refrence);
if (expenseId) {
return (
<li key={index}>
<Link
href={`/expense/detail/?expenseId=${expenseId}`}
className='p-0 h-auto text-primary underline'
>
{exp.refrence}
</Link>
</li>
);
}
return <li key={index}>{exp.refrence}</li>;
})}
</ul>
);
@@ -305,14 +287,6 @@ const PurchaseTable = () => {
? formatDate(props.row.original.po_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'received_date',
header: 'Tgl. Terima',
cell: (props) =>
props.row.original.received_date
? formatDate(props.row.original.received_date, 'DD MMM YYYY')
: '-',
},
{
accessorKey: 'due_date',
header: 'Jatuh Tempo',
@@ -409,17 +383,10 @@ const PurchaseTable = () => {
setIsDeleteLoading(true);
try {
const deleteResponse = await PurchaseApi.delete(
selectedPurchase?.id as number
);
if (isResponseSuccess(deleteResponse)) {
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} else {
toast.error(deleteResponse?.message ?? 'Gagal menghapus data!');
}
await PurchaseApi.delete(selectedPurchase?.id as number);
refreshPurchaseRequests();
deleteModal.closeModal();
toast.success('Berhasil menghapus data permintaan pembelian!');
} catch {
toast.error('Gagal menghapus data permintaan pembelian!');
}
@@ -427,190 +394,29 @@ const PurchaseTable = () => {
setIsDeleteLoading(false);
}, [selectedPurchase?.id, refreshPurchaseRequests, deleteModal]);
useEffect(() => {
updateFilter('search', searchValue);
}, [searchValue, updateFilter]);
useEffect(() => {
setTableState('purchase-table', pathname);
}, [pathname, setTableState]);
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
setSearchValue(e.target.value);
updateFilter('search', e.target.value);
},
[updateFilter]
[updateFilter, setSearchValue]
);
const filterSubmitHandler = (values: PurchaseFilter) => {
setFilters({
po_date: values.poDate,
product_category_id: values.category.join(','),
product_category_name:
values.category_labels?.map((item) => item.label).join(',') || '',
approval_status: values.status.join(','),
supplier_id: values.supplier_id ? String(values.supplier_id) : '',
supplier_name: values.supplier_label || '',
area_id: values.area_id ? String(values.area_id) : '',
area_name: values.area_label || '',
location_id: values.location_id ? String(values.location_id) : '',
location_name: values.location_label || '',
project_flock_id: values.project_flock_id
? String(values.project_flock_id)
: '',
project_flock_name: values.project_flock_label || '',
project_flock_kandang_id: values.project_flock_kandang_id
? String(values.project_flock_kandang_id)
: '',
project_flock_kandang_name: values.project_flock_kandang_label || '',
});
};
const filterResetHandler = () => {
setFilters({
po_date: '',
product_category_id: '',
product_category_name: '',
approval_status: '',
supplier_id: '',
supplier_name: '',
area_id: '',
area_name: '',
location_id: '',
location_name: '',
project_flock_id: '',
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
});
};
const purchaseFilterInitialValues = useMemo(() => {
const categoryIds = tableFilterState.product_category_id
? tableFilterState.product_category_id
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const categoryLabels = tableFilterState.product_category_name
? tableFilterState.product_category_name
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
const approvalStatuses = tableFilterState.approval_status
? tableFilterState.approval_status
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return {
poDate: tableFilterState.po_date,
category: categoryIds.map((value, index) => ({
value: Number(value),
label: categoryLabels[index] || value,
})),
status: approvalStatuses.map((value) => ({
value,
label:
PURCHASE_ORDER_APPROVAL_LINE.find((item) => item.step_name === value)
?.step_name || value,
})),
supplier: tableFilterState.supplier_id
? ({
value: Number(tableFilterState.supplier_id),
label:
tableFilterState.supplier_name || tableFilterState.supplier_id,
} as OptionType<number>)
: null,
area: tableFilterState.area_id
? ({
value: Number(tableFilterState.area_id),
label: tableFilterState.area_name || tableFilterState.area_id,
} as OptionType<number>)
: null,
location: tableFilterState.location_id
? ({
value: Number(tableFilterState.location_id),
label:
tableFilterState.location_name || tableFilterState.location_id,
} as OptionType<number>)
: null,
project_flock: tableFilterState.project_flock_id
? ({
value: Number(tableFilterState.project_flock_id),
label:
tableFilterState.project_flock_name ||
tableFilterState.project_flock_id,
} as OptionType<number>)
: null,
project_flock_kandang: tableFilterState.project_flock_kandang_id
? ({
value: Number(tableFilterState.project_flock_kandang_id),
label:
tableFilterState.project_flock_kandang_name ||
tableFilterState.project_flock_kandang_id,
} as OptionType<number>)
: null,
};
}, [tableFilterState]);
const exportToExcel = useCallback(async () => {
setIsLoadingExportingToExcel(true);
try {
await PurchaseApi.exportToExcel(getTableFilterQueryString());
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor data pembelian')
);
} finally {
setIsLoadingExportingToExcel(false);
}
}, [getTableFilterQueryString]);
const resetExportProgressForm = useCallback(() => {
setExportProgressStartDate('');
setExportProgressEndDate('');
}, []);
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressStartDate(e.target.value);
}, []);
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> =
useCallback((e) => {
setExportProgressEndDate(e.target.value);
}, []);
const exportProgressInputToExcelClickHandler = useCallback(() => {
resetExportProgressForm();
exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]);
const submitExportProgressInputHandler = useCallback(async () => {
if (!exportProgressStartDate || !exportProgressEndDate) {
return;
}
setIsExportProgressLoading(true);
try {
await PurchaseApi.exportInputProgressToExcel(
exportProgressStartDate,
exportProgressEndDate
);
exportProgressInputModal.closeModal();
resetExportProgressForm();
toast.success('Ekspor berhasil');
} catch (error) {
toast.error(
await getErrorMessage(error, 'Gagal mengekspor input progress')
);
} finally {
setIsExportProgressLoading(false);
}
}, [
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
// const pageSizeChangeHandler = useCallback(
// (val: OptionType | OptionType[] | null) => {
// const newVal = val as OptionType;
// setPageSize(newVal.value as number);
// },
// [setPageSize]
// );
return (
<>
@@ -649,81 +455,6 @@ const PurchaseTable = () => {
'placeholder:font-semibold placeholder:text-base-content/50',
}}
/>
<ButtonFilter
values={tableFilterState}
excludeFields={[
'page',
'pageSize',
'search',
'filter_by',
'sort_by',
'product_category_name',
'supplier_name',
'area_name',
'location_name',
'project_flock_name',
'project_flock_kandang_name',
]}
fieldGroups={[['startDate', 'endDate']]}
onClick={filterModal.openModal}
className='px-3 py-2.5'
/>
<Dropdown
align='end'
direction='bottom'
className={{
content:
'mt-1 rounded-xl border border-base-content/5 shadow-sm overflow-hidden',
}}
trigger={
<Button
variant='outline'
color='none'
className='px-3 py-2.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft'
>
<div className='flex flex-row items-center gap-1.5'>
<Icon
icon='heroicons:cloud-arrow-down'
width={20}
height={20}
/>
<span>Export</span>
<div className='w-px self-stretch bg-base-content/10' />
<Icon
icon='heroicons:chevron-down'
width={14}
height={14}
/>
</div>
</Button>
}
>
<Button
variant='ghost'
color='none'
onClick={exportToExcel}
isLoading={isLoadingExportingToExcel}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor ke Excel
</Button>
<Button
variant='ghost'
color='none'
onClick={exportProgressInputToExcelClickHandler}
className='w-full p-3 justify-start text-sm text-base-content/50 font-semibold text-nowrap'
>
<Icon icon='heroicons:table-cells' width={20} height={20} />
Ekspor Input Progress (Excel)
</Button>
</Dropdown>
</div>
</div>
@@ -782,14 +513,6 @@ const PurchaseTable = () => {
</div>
{/* ===== MODAL COMPONENTS ===== */}
<PurchaseFilterModal
ref={filterModal.ref}
initialValues={purchaseFilterInitialValues}
onSubmit={filterSubmitHandler}
onReset={filterResetHandler}
/>
<ConfirmationModal
ref={deleteModal.ref}
type='error'
@@ -805,76 +528,6 @@ const PurchaseTable = () => {
onClick: confirmationModalDeleteClickHandler,
}}
/>
<Modal
ref={exportProgressInputModal.ref}
className={{
modalBox: 'max-w-lg rounded-lg p-0',
}}
closeOnBackdrop
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Ekspor Input Progress
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='export_progress_start_date'
label='Tanggal Mulai'
value={exportProgressStartDate}
onChange={exportProgressStartDateChangeHandler}
isNestedModal
required
/>
<DateInput
name='export_progress_end_date'
label='Tanggal Selesai'
value={exportProgressEndDate}
onChange={exportProgressEndDateChangeHandler}
isNestedModal
required
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
exportProgressInputModal.closeModal();
resetExportProgressForm();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={submitExportProgressInputHandler}
isLoading={isExportProgressLoading}
disabled={!exportProgressStartDate || !exportProgressEndDate}
className='px-3 py-2.5'
>
Submit
</Button>
</div>
</div>
</Modal>
</>
);
};
@@ -294,6 +294,7 @@ const PurchaseOrderAcceptApprovalForm = ({
item.expedition_vendor_id || item.expedition_vendor?.id || null;
return {
purchase_item: null,
purchase_item_id: item.id,
received_date: item.received_date
? new Date(item.received_date).toISOString().split('T')[0]
@@ -307,7 +308,7 @@ const PurchaseOrderAcceptApprovalForm = ({
}
: null,
expedition_vendor_id: expeditionVendorId,
received_qty: item.sub_qty || '',
received_qty: item.total_qty || '',
transport_per_item: item.transport_per_item || '',
};
});
@@ -366,9 +367,6 @@ const PurchaseOrderAcceptApprovalForm = ({
);
} else {
formik.setFieldValue(`items.${idx}.expedition_vendor_id`, null);
formik.setFieldValue(`items.${idx}.transport_per_item`, null);
formik.setFieldValue(`items.${idx}.vehicle_number`, null);
}
};
@@ -555,7 +553,6 @@ const PurchaseOrderAcceptApprovalForm = ({
)
}
onBlur={formik.handleBlur}
disabled={!Boolean(formItem?.expedition_vendor)}
isError={
isRepeaterInputError(idx, 'vehicle_number').isError
}
@@ -572,7 +569,7 @@ const PurchaseOrderAcceptApprovalForm = ({
<td>
<SelectInput
isClearable={true}
value={formItem?.expedition_vendor ?? null}
value={formItem?.expedition_vendor}
key={`expedition-vendor-${idx}`}
onChange={(val) =>
expeditionVendorChangeHandler(idx, val)
@@ -660,7 +657,6 @@ const PurchaseOrderAcceptApprovalForm = ({
thousandSeparator=','
decimalSeparator='.'
inputPrefix={'Rp'}
disabled={!Boolean(formItem?.expedition_vendor)}
isError={
isRepeaterInputError(idx, 'transport_per_item')
.isError
@@ -31,6 +31,10 @@ type PurchaseRequestAcceptApprovalFormSchemaType = {
action: 'APPROVED' | 'REJECTED';
notes: string | null;
items: {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -64,6 +68,10 @@ export type PurchaseStaffApprovalItemSchema = {
};
export type PurchaseAcceptApprovalItemSchema = {
purchase_item?: {
value: number;
label: string;
} | null;
purchase_item_id: number;
received_date: string;
travel_number: string;
@@ -152,6 +160,12 @@ const PurchaseManagerApprovalObjectSchema: Yup.ObjectSchema<PurchaseRequestManag
const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApprovalItemSchema> =
Yup.object({
purchase_item: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.nullable()
.optional(),
purchase_item_id: Yup.number()
.min(1, 'Purchase item is required!')
.required('Purchase item is required!')
@@ -171,17 +185,12 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('No. Surat jalan wajib diisi!'),
vehicle_number: Yup.string()
.nullable()
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) => schema.required('Nomor kendaraan wajib diisi!'),
otherwise: (schema) => schema.optional(),
})
.optional()
.typeError('Nomor kendaraan harus berupa plat nomor!'),
expedition_vendor: Yup.object({
value: Yup.number().min(1).required(),
label: Yup.string().required(),
})
.default(undefined)
.nullable()
.optional(),
expedition_vendor_id: Yup.number()
@@ -204,12 +213,7 @@ const PurchaseAcceptApprovalItemObjectSchema: Yup.ObjectSchema<PurchaseAcceptApp
.typeError('Jumlah diterima harus berupa angka!'),
transport_per_item: Yup.mixed<string | number>()
.nullable()
.when('expedition_vendor_id', {
is: (expeditionVendorId?: number | null) => Boolean(expeditionVendorId),
then: (schema) =>
schema.required('Biaya transport per item wajib diisi!'),
otherwise: (schema) => schema.optional(),
})
.optional()
.test(
'is-valid-transport-per-item',
'Biaya transport per item harus berupa angka lebih dari atau sama dengan 0!',
@@ -55,6 +55,7 @@ const PurchaseRequestForm = ({
const deleteModal = useModal();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [, setLocationSelectInputValue] = useState('');
const [selectedPurchaseItems, setSelectedPurchaseItems] = useState<number[]>(
[]
);
@@ -162,7 +163,6 @@ const PurchaseRequestForm = ({
options: locationOptions,
isLoadingOptions: isLoadingLocations,
loadMore: loadMoreLocations,
setInputValue: setLocationSelectInputValue,
} = useSelect(LocationApi.basePath, 'id', 'name', '', {
area_id:
selectedArea != ''
@@ -26,8 +26,6 @@ import PurchaseOrderAcceptApprovalForm from '@/components/pages/purchase/form/or
import PurchaseOrderInvoice from '@/components/pages/purchase/order/PurchaseOrderInvoice';
import Card from '@/components/Card';
import DateInput from '@/components/input/DateInput';
import TextArea from '@/components/input/TextArea';
import {
CreateAcceptApprovalRequestPayload,
CreateManagerApprovalRequestPayload,
@@ -98,7 +96,6 @@ const PurchaseOrderDetail = ({
const acceptRejectionModal = useModal();
const managerRejectionModal = useModal();
const editModal = useModal();
const editPoDateModal = useModal();
const penerimaanBarangModal = useModal();
const deleteModal = useModal();
@@ -108,9 +105,6 @@ const PurchaseOrderDetail = ({
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<PurchaseItem | null>(null);
const [, setApprovalNotes] = useState('');
const [managerApprovalNotes, setManagerApprovalNotes] = useState('');
const [managerApprovalPoDate, setManagerApprovalPoDate] = useState('');
const [editPoDate, setEditPoDate] = useState('');
const selectedRowIds = Object.keys(rowSelection).map((item) =>
parseInt(item)
@@ -218,8 +212,6 @@ const PurchaseOrderDetail = ({
break;
case 2:
setApprovalNotes('');
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.openModal();
break;
case 3:
@@ -422,50 +414,17 @@ const PurchaseOrderDetail = ({
deleteModal,
]);
const updatePoDateHandler = useCallback(async () => {
const purchaseRequestId = searchParams.get('purchaseId')
? parseInt(searchParams.get('purchaseId')!)
: initialValues?.id || 1;
if (!purchaseRequestId) {
toast.error('Purchase Request ID is required');
return;
}
const res = await PurchaseApi.updatePoDate(purchaseRequestId, {
po_date: editPoDate,
});
if (isResponseError(res)) {
toast.error(res.message || 'Gagal mengubah tanggal PO');
return;
}
toast.success('Tanggal PO berhasil diubah');
setEditPoDate('');
editPoDateModal.closeModal();
refetchData?.();
}, [
initialValues?.id,
searchParams,
editPoDate,
editPoDateModal,
refetchData,
]);
// ===== APPROVAL/REJECTION HANDLERS =====
const managerApprovalHandler = async () => {
const managerApprovalHandler = async (notes: string) => {
const payload: CreateManagerApprovalRequestPayload = {
action: 'APPROVED',
notes: managerApprovalNotes || null,
po_date: managerApprovalPoDate || null,
notes: notes || null,
};
await createManagerApprovalHandler(payload);
await refreshApprovals();
await refetchData?.();
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
setApprovalNotes('');
confirmationModalWithNotes.closeModal();
};
@@ -870,41 +829,6 @@ const PurchaseOrderDetail = ({
</div>
</div>
</div>
{purchaseData.po_date &&
!purchaseData.po_date.startsWith('0001') && (
<div className='group'>
<div className='flex items-start'>
<span className='font-medium text-gray-600 min-w-[140px] shrink-0'>
Tanggal PO
</span>
<div className='ml-3 flex items-center gap-1'>
<span className='text-gray-900'>
: {formatDate(purchaseData.po_date, 'DD MMM YYYY')}
</span>
<RequirePermission permissions='lti.purchase.update'>
<Button
type='button'
variant='ghost'
color='warning'
className='p-1 min-h-0 h-auto'
onClick={() => {
setEditPoDate(
formatDate(purchaseData.po_date, 'YYYY-MM-DD')
);
editPoDateModal.openModal();
}}
>
<Icon
icon='material-symbols:edit-outline'
width={14}
height={14}
/>
</Button>
</RequirePermission>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1092,79 +1016,27 @@ const PurchaseOrderDetail = ({
</div>
</Card>
{/* Manager Approval Modal */}
<Modal
{/* Confirmation Modal with Notes */}
<ConfirmationModalWithNotes
ref={confirmationModalWithNotes.ref}
type='success'
text='Apakah Anda yakin ingin melanjutkan approval ini?'
placeholder='(Opsional) Tambahkan catatan untuk approval ini...'
rows={4}
closeOnBackdrop
className={{
modalBox: 'max-w-lg rounded-lg p-0',
primaryButton={{
text: 'Ya, Lanjutkan',
color: 'success',
onClick: managerApprovalHandler,
}}
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Konfirmasi Approval Manager
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<p className='text-sm text-base-content/70'>
Apakah Anda yakin ingin melanjutkan approval ini?
</p>
<DateInput
name='manager_approval_po_date'
label='Tanggal PO'
value={managerApprovalPoDate}
onChange={(e) => setManagerApprovalPoDate(e.target.value)}
isNestedModal
/>
<TextArea
name='manager_approval_notes'
label='Catatan (Opsional)'
placeholder='Tambahkan catatan untuk approval ini...'
value={managerApprovalNotes}
onChange={(e) => setManagerApprovalNotes(e.target.value)}
rows={4}
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setManagerApprovalNotes('');
setManagerApprovalPoDate('');
confirmationModalWithNotes.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='success'
onClick={managerApprovalHandler}
className='px-3 py-2.5'
>
Ya, Lanjutkan
</Button>
</div>
</div>
</Modal>
secondaryButton={{
text: 'Batal',
onClick: () => {
setApprovalNotes('');
confirmationModalWithNotes.closeModal();
},
}}
/>
{/* Staff Approval Modal */}
<Modal
@@ -1240,66 +1112,6 @@ const PurchaseOrderDetail = ({
/>
</Modal>
{/* Edit PO Date Modal */}
<Modal
ref={editPoDateModal.ref}
closeOnBackdrop
className={{
modalBox: 'max-w-sm rounded-lg p-0',
}}
>
<div className='flex flex-col'>
<div className='flex items-center justify-between border-b border-base-content/10 p-4'>
<h4 className='text-sm font-semibold text-base-content'>
Edit Tanggal PO
</h4>
<Button
variant='ghost'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='p-1'
>
<Icon icon='mdi:close' width={20} height={20} />
</Button>
</div>
<div className='flex flex-col gap-4 p-4'>
<DateInput
name='edit_po_date'
label='Tanggal PO'
value={editPoDate}
onChange={(e) => setEditPoDate(e.target.value)}
isNestedModal
/>
</div>
<div className='flex justify-end gap-3 border-t border-base-content/10 p-4'>
<Button
variant='outline'
color='none'
onClick={() => {
setEditPoDate('');
editPoDateModal.closeModal();
}}
className='px-3 py-2.5'
>
Batal
</Button>
<Button
color='primary'
onClick={updatePoDateHandler}
className='px-3 py-2.5'
disabled={!editPoDate}
>
Simpan
</Button>
</div>
</div>
</Modal>
{/* Staff Rejection Modal */}
<ConfirmationModalWithNotes
ref={staffRejectionModal.ref}
@@ -34,7 +34,7 @@ const pdfStyles = StyleSheet.create({
marginBottom: 20,
},
logo: {
width: 30,
width: 120,
height: 30,
marginBottom: 8,
},
@@ -265,7 +265,7 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
<View style={pdfStyles.header}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image
src='/assets/img/lti-logo.png'
src={'https://placehold.co/120x30/png'}
style={pdfStyles.logo}
id={'mbu-logo'}
/>
@@ -273,8 +273,8 @@ const PurchaseOrderInvoice = ({ data }: PurchaseOrderInvoiceProps) => {
PT LUMBUNG TELUR INDONESIA
</Text>
<Text style={pdfStyles.address}>
Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten
Bandung Barat, Jawa Barat 40514
SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel.
Cipedes, Kec. Sukajadi, Kota Bandung 40162
</Text>
<View style={pdfStyles.divider} />
</View>
@@ -4,8 +4,7 @@ import { useState } from 'react';
import Tabs from '@/components/Tabs';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import ReportExpenseTab from '@/components/pages/report/expense/tab/ReportExpenseTab';
import ReportDepreciationTab from '@/components/pages/report/expense/tab/ReportDepreciationTab';
import ReportExpenseTab from './tab/ReportExpenseTab';
const ReportExpenseTabs = () => {
const [activeTabId, setActiveTabId] = useState<string>('1');
@@ -17,11 +16,6 @@ const ReportExpenseTabs = () => {
label: 'Laporan Biaya Operasional',
content: <ReportExpenseTab tabId={'1'} />,
},
{
id: '2',
label: 'Laporan Depresiasi',
content: <ReportDepreciationTab tabId={'2'} />,
},
];
return (
@@ -47,7 +47,7 @@ export const generateReportExpensePDF = async (
doc.setFontSize(7);
doc.setTextColor(102, 102, 102);
doc.text(
'Setra Duta Raya No.L3 No.7, Ciwaruga, Kec. Parongpong, Kabupaten Bandung Barat, Jawa Barat 40514',
'SOHO Building Lt.3 (Paris Van Java), Jalan Karang Tinggal, Kel. Cipedes, Kec. Sukajadi, Kota Bandung 40162',
marginX,
25
);
@@ -1,26 +1,27 @@
import React from 'react';
import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton';
import Table from '@/components/Table';
import { ReportExpense } from '@/types/api/report/report-expense';
import { ColumnDef } from '@tanstack/react-table';
type ReportSkeletonColumn<TData extends object> =
| ColumnDef<TData>
type ReportExpenseColumn =
| ColumnDef<ReportExpense>
| {
header: string;
columns: Array<{
header: 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,
icon,
title,
subtitle,
}: {
columns: ReportSkeletonColumn<TData>[];
columns: ReportExpenseColumn[];
icon: React.ReactNode;
title: string;
subtitle: string;

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