mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
Merge branch 'development' into 'production'
Revert "fixing devops" See merge request mbugroup/lti-web-client!449
This commit is contained in:
@@ -63,3 +63,159 @@ 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 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const AreasTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const AreasTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'areas-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const AreasTable = () => {
|
||||
const [selectedArea, setSelectedArea] = useState<Area | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('areas-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Bank } from '@/types/api/master-data/bank';
|
||||
import { BankApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const BanksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const BanksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'banks-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -137,17 +134,8 @@ const BanksTable = () => {
|
||||
const [selectedBank, setSelectedBank] = useState<Bank | undefined>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('banks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Customer } from '@/types/api/master-data/customer';
|
||||
import { CustomerApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const CustomersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const CustomersTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'customers-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const CustomersTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('customers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Flock } from '@/types/api/master-data/flock';
|
||||
import { FlockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const FlockTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,12 +109,14 @@ const FlockTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'flock-table',
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
@@ -139,17 +136,8 @@ const FlockTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('flocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -35,7 +28,6 @@ import { User } from '@/types/api/api-general';
|
||||
import { formatNumber } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
KandangFilterSchema,
|
||||
KandangFilterType,
|
||||
@@ -122,20 +114,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const KandangsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
locationFilter?: OptionType<string>;
|
||||
picFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
locationFilter: '',
|
||||
picFilter: '',
|
||||
locationFilter: undefined,
|
||||
picFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -143,6 +136,8 @@ const KandangsTable = () => {
|
||||
locationFilter: 'location_id',
|
||||
picFilter: 'pic_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'kandangs-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -151,22 +146,34 @@ const KandangsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<KandangFilterType>({
|
||||
initialValues: {
|
||||
location_id: null,
|
||||
pic_id: null,
|
||||
location: tableFilterState.locationFilter,
|
||||
pic: tableFilterState.picFilter,
|
||||
},
|
||||
validationSchema: KandangFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('locationFilter', values.location_id || '');
|
||||
updateFilter('picFilter', values.pic_id || '');
|
||||
updateFilter('locationFilter', values.location || undefined, true);
|
||||
updateFilter('picFilter', values.pic || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('locationFilter', '');
|
||||
updateFilter('picFilter', '');
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('locationFilter', undefined, true);
|
||||
updateFilter('picFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
location: undefined,
|
||||
pic: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== LOCATION OPTIONS =====
|
||||
const {
|
||||
setInputValue: setLocationInputValue,
|
||||
@@ -194,43 +201,15 @@ const KandangsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterLocationChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const location = val as OptionType | null;
|
||||
const locationId = location?.value ? String(location.value) : null;
|
||||
const handleFilterLocationChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('location', val);
|
||||
};
|
||||
|
||||
formik.setFieldValue('location_id', locationId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
const handleFilterPicChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const pic = val as OptionType | null;
|
||||
const picId = pic?.value ? String(pic.value) : null;
|
||||
|
||||
formik.setFieldValue('pic_id', picId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const locationIdValue = useMemo(() => {
|
||||
if (!formik.values.location_id) return null;
|
||||
return (
|
||||
locationOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.location_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.location_id, locationOptions]);
|
||||
|
||||
const picIdValue = useMemo(() => {
|
||||
if (!formik.values.pic_id) return null;
|
||||
return (
|
||||
picOptions.find((opt) => String(opt.value) === formik.values.pic_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.pic_id, picOptions]);
|
||||
const handleFilterPicChange = (val: OptionType | OptionType[] | null) => {
|
||||
setFieldValue('pic', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -255,17 +234,8 @@ const KandangsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('kandangs-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -475,13 +445,13 @@ const KandangsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Lokasi'
|
||||
placeholder='Pilih Lokasi'
|
||||
options={locationOptions}
|
||||
value={locationIdValue}
|
||||
value={formik.values.location}
|
||||
onChange={handleFilterLocationChange}
|
||||
onInputChange={setLocationInputValue}
|
||||
isLoading={isLoadingLocationOptions}
|
||||
@@ -494,7 +464,7 @@ const KandangsTable = () => {
|
||||
label='PIC'
|
||||
placeholder='Pilih PIC'
|
||||
options={picOptions}
|
||||
value={picIdValue}
|
||||
value={formik.values.pic}
|
||||
onChange={handleFilterPicChange}
|
||||
onInputChange={setPicInputValue}
|
||||
isLoading={isLoadingPicOptions}
|
||||
@@ -510,17 +480,14 @@ const KandangsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
|
||||
disabled={!formik.isValid || formik.isSubmitting}
|
||||
disabled={!formik.isValid}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const KandangFilterSchema = object().shape({
|
||||
location_id: string().nullable(),
|
||||
pic_id: string().nullable(),
|
||||
export const KandangFilterSchema = Yup.object().shape({
|
||||
location: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
|
||||
pic: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type KandangFilterType = {
|
||||
location_id: string | null;
|
||||
pic_id: string | null;
|
||||
location?: OptionType<string>;
|
||||
pic?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -32,7 +25,6 @@ import { Area } from '@/types/api/master-data/area';
|
||||
import { LocationApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
LocationFilterSchema,
|
||||
LocationFilterType,
|
||||
@@ -118,25 +110,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const LocationsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
areaFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
areaFilter: '',
|
||||
areaFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
areaFilter: 'area_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'locations-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -145,19 +139,28 @@ const LocationsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<LocationFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
area: tableFilterState.areaFilter,
|
||||
},
|
||||
validationSchema: LocationFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area || undefined, true);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
setInputValue: setAreaInputValue,
|
||||
@@ -172,24 +175,9 @@ const LocationsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterAreaChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const area = val as OptionType | null;
|
||||
const areaId = area?.value ? String(area.value) : null;
|
||||
|
||||
formik.setFieldValue('area_id', areaId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const areaIdValue = useMemo(() => {
|
||||
if (!formik.values.area_id) return null;
|
||||
return (
|
||||
areaOptions.find((opt) => String(opt.value) === formik.values.area_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.area_id, areaOptions]);
|
||||
const handleFilterAreaChange = (val: OptionType | OptionType[] | null) => {
|
||||
formik.setFieldValue('area', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -212,19 +200,10 @@ const LocationsTable = () => {
|
||||
>(undefined);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('locations-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -425,13 +404,13 @@ const LocationsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
placeholder='Pilih Area'
|
||||
options={areaOptions}
|
||||
value={areaIdValue}
|
||||
value={formik.values.area}
|
||||
onChange={handleFilterAreaChange}
|
||||
onInputChange={setAreaInputValue}
|
||||
isLoading={isLoadingAreaOptions}
|
||||
@@ -447,10 +426,7 @@ const LocationsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const LocationFilterSchema = object().shape({
|
||||
area_id: string().nullable(),
|
||||
export const LocationFilterSchema = Yup.object().shape({
|
||||
area: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type LocationFilterType = {
|
||||
area_id: string | null;
|
||||
area?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ import { Nonstock } from '@/types/api/master-data/nonstock';
|
||||
import { NonstockApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const NonstocksTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const NonstocksTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'nonstock-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('nonstocks-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -148,8 +137,7 @@ const NonstocksTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,7 +20,6 @@ import { ProductCategory } from '@/types/api/master-data/product-category';
|
||||
import { ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductCategoryTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -120,12 +115,10 @@ const ProductCategoryTable = () => {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-category-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -144,8 +137,7 @@ const ProductCategoryTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -214,10 +206,6 @@ const ProductCategoryTable = () => {
|
||||
[tableFilterState.pageSize, tableFilterState.page, deleteModal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-category-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full'>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -33,7 +26,6 @@ import { ProductApi, ProductCategoryApi } from '@/services/api/master-data';
|
||||
import { formatCurrency } from '@/lib/helper';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
ProductFilterSchema,
|
||||
ProductFilterType,
|
||||
@@ -119,25 +111,27 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const ProductsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
productCategoryFilter?: OptionType<string>;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
productCategoryFilter: '',
|
||||
productCategoryFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
productCategoryFilter: 'product_category_id',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'product-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,19 +140,32 @@ const ProductsTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductFilterType>({
|
||||
initialValues: {
|
||||
product_category_id: null,
|
||||
product_category: tableFilterState.productCategoryFilter,
|
||||
},
|
||||
validationSchema: ProductFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('productCategoryFilter', values.product_category_id || '');
|
||||
updateFilter(
|
||||
'productCategoryFilter',
|
||||
values.product_category || undefined,
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('productCategoryFilter', '');
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('productCategoryFilter', undefined, true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
product_category: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PRODUCT CATEGORY OPTIONS =====
|
||||
const {
|
||||
setInputValue: setProductCategoryInputValue,
|
||||
@@ -173,25 +180,11 @@ const ProductsTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterProductCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const category = val as OptionType | null;
|
||||
const categoryId = category?.value ? String(category.value) : null;
|
||||
|
||||
formik.setFieldValue('product_category_id', categoryId);
|
||||
},
|
||||
[formik]
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const productCategoryIdValue = useMemo(() => {
|
||||
if (!formik.values.product_category_id) return null;
|
||||
return (
|
||||
productCategoryOptions.find(
|
||||
(opt) => String(opt.value) === formik.values.product_category_id
|
||||
) || null
|
||||
);
|
||||
}, [formik.values.product_category_id, productCategoryOptions]);
|
||||
const handleFilterProductCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
formik.setFieldValue('product_category', val);
|
||||
};
|
||||
|
||||
// ===== HANDLE FILTER MODAL OPEN =====
|
||||
const handleFilterModalOpen = () => {
|
||||
@@ -199,10 +192,6 @@ const ProductsTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -220,13 +209,8 @@ const ProductsTable = () => {
|
||||
);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('product-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -477,13 +461,13 @@ const ProductsTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Kategori Produk'
|
||||
placeholder='Pilih Kategori Produk'
|
||||
options={productCategoryOptions}
|
||||
value={productCategoryIdValue}
|
||||
value={formik.values.product_category}
|
||||
onChange={handleFilterProductCategoryChange}
|
||||
onInputChange={setProductCategoryInputValue}
|
||||
isLoading={isLoadingProductCategoryOptions}
|
||||
@@ -499,10 +483,7 @@ const ProductsTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { string, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const ProductFilterSchema = object().shape({
|
||||
product_category_id: string().nullable(),
|
||||
export const ProductFilterSchema = Yup.object().shape({
|
||||
product_category: Yup.object({
|
||||
value: Yup.string().nullable(),
|
||||
label: Yup.string().nullable(),
|
||||
}).nullable(),
|
||||
});
|
||||
|
||||
export type ProductFilterType = {
|
||||
product_category_id: string | null;
|
||||
product_category?: OptionType<string>;
|
||||
};
|
||||
|
||||
@@ -128,27 +128,44 @@ const ProductionStandardTable = () => {
|
||||
pageSize: 'limit',
|
||||
projectCategoryFilter: 'project_category',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'production-standard-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
const filterModal = useModal();
|
||||
|
||||
// ===== FILTER INITIAL VALUES (derived from persisted state) =====
|
||||
const filterInitialValues = useMemo<ProductionStandardFilterType>(
|
||||
() => ({
|
||||
project_category: tableFilterState.projectCategoryFilter || null,
|
||||
}),
|
||||
[tableFilterState.projectCategoryFilter]
|
||||
);
|
||||
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<ProductionStandardFilterType>({
|
||||
initialValues: {
|
||||
project_category: null,
|
||||
},
|
||||
initialValues: filterInitialValues,
|
||||
validationSchema: ProductionStandardFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('projectCategoryFilter', values.project_category || '');
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('projectCategoryFilter', '');
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('projectCategoryFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
project_category: null,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== PROJECT CATEGORY OPTIONS (GROWING or LAYING) =====
|
||||
const projectCategoryOptions = useMemo(
|
||||
() => [
|
||||
@@ -381,7 +398,7 @@ const ProductionStandardTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
@@ -397,13 +414,9 @@ const ProductionStandardTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -30,7 +29,7 @@ import { Supplier } from '@/types/api/master-data/supplier';
|
||||
import { SupplierApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
import {
|
||||
SupplierFilterSchema,
|
||||
SupplierFilterType,
|
||||
@@ -117,20 +116,21 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const SuppliersTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
} = useTableFilter<{
|
||||
search: string;
|
||||
categoryFilter?: OptionType<string>;
|
||||
flagFilter?: string;
|
||||
}>({
|
||||
initial: {
|
||||
search: '',
|
||||
categoryFilter: '',
|
||||
flagFilter: '',
|
||||
categoryFilter: undefined,
|
||||
flagFilter: undefined,
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
@@ -138,6 +138,8 @@ const SuppliersTable = () => {
|
||||
categoryFilter: 'category_id',
|
||||
flagFilter: 'flag',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'supplier-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -146,26 +148,33 @@ const SuppliersTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<SupplierFilterType>({
|
||||
initialValues: {
|
||||
category_id: null,
|
||||
flag: false,
|
||||
category: tableFilterState.categoryFilter,
|
||||
flag: tableFilterState.flagFilter === 'EKSPEDISI',
|
||||
},
|
||||
validationSchema: SupplierFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('categoryFilter', values.category_id || '');
|
||||
updateFilter(
|
||||
'flagFilter',
|
||||
values.flag === true ? 'EKSPEDISI' : values.flag === false ? '' : ''
|
||||
);
|
||||
updateFilter('categoryFilter', values.category || undefined, true);
|
||||
updateFilter('flagFilter', values.flag === true ? 'EKSPEDISI' : '', true);
|
||||
filterModal.closeModal();
|
||||
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('categoryFilter', '');
|
||||
updateFilter('flagFilter', '');
|
||||
formik.setFieldValue('flag', false);
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('categoryFilter', undefined, true);
|
||||
updateFilter('flagFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
category: undefined,
|
||||
flag: false,
|
||||
},
|
||||
});
|
||||
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
|
||||
// ===== CATEGORY OPTIONS (SAPRONAK or BOP) =====
|
||||
@@ -187,15 +196,11 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HANDLERS =====
|
||||
const handleFilterCategoryChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
const option = val as OptionType | null;
|
||||
const categoryId = option?.value ? String(option.value) : null;
|
||||
|
||||
setFieldValue('category_id', categoryId);
|
||||
},
|
||||
[setFieldValue]
|
||||
);
|
||||
const handleFilterCategoryChange = (
|
||||
val: OptionType | OptionType[] | null
|
||||
) => {
|
||||
setFieldValue('category', val);
|
||||
};
|
||||
|
||||
const handleFilterFlagChange = useCallback(
|
||||
(val: OptionType | OptionType[] | null) => {
|
||||
@@ -213,13 +218,13 @@ const SuppliersTable = () => {
|
||||
);
|
||||
|
||||
// ===== FILTER HELPERS =====
|
||||
const categoryIdValue = useMemo(() => {
|
||||
if (!formik.values.category_id) return null;
|
||||
return (
|
||||
categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
null
|
||||
);
|
||||
}, [formik.values.category_id, categoryOptions]);
|
||||
// const categoryIdValue = useMemo(() => {
|
||||
// if (!formik.values.category_id) return null;
|
||||
// return (
|
||||
// categoryOptions.find((opt) => opt.value === formik.values.category_id) ||
|
||||
// null
|
||||
// );
|
||||
// }, [formik.values.category_id, categoryOptions]);
|
||||
|
||||
const flagValue = useMemo(() => {
|
||||
if (formik.values.flag === null) return null;
|
||||
@@ -243,14 +248,6 @@ const SuppliersTable = () => {
|
||||
}
|
||||
}, [filterModal.open, tableFilterState.flagFilter, setFieldValue]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('suppliers-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -269,8 +266,7 @@ const SuppliersTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -491,13 +487,13 @@ const SuppliersTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInputRadio
|
||||
label='Kategori'
|
||||
placeholder='Pilih Kategori'
|
||||
options={categoryOptions}
|
||||
value={categoryIdValue}
|
||||
value={formik.values.category}
|
||||
onChange={handleFilterCategoryChange}
|
||||
isClearable
|
||||
className={{ wrapper: 'w-full' }}
|
||||
@@ -517,13 +513,9 @@ const SuppliersTable = () => {
|
||||
{/* Modal Footer */}
|
||||
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
|
||||
<Button
|
||||
type='button'
|
||||
type='reset'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { string, boolean, object } from 'yup';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const SupplierFilterSchema = object().shape({
|
||||
category_id: string().nullable(),
|
||||
flag: boolean().nullable(),
|
||||
export const SupplierFilterSchema = Yup.object().shape({
|
||||
category: Yup.object({
|
||||
value: Yup.string().required(),
|
||||
label: Yup.string().required(),
|
||||
}).nullable(),
|
||||
|
||||
flag: Yup.boolean().nullable(),
|
||||
});
|
||||
|
||||
export type SupplierFilterType = {
|
||||
category_id: string | null;
|
||||
category?: OptionType<string>;
|
||||
flag: boolean | null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, useEffect, useMemo, useState } from 'react';
|
||||
import { ChangeEventHandler, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ import { Uom } from '@/types/api/master-data/uom';
|
||||
import { UomApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
|
||||
const RowOptionsMenu = ({
|
||||
popoverPosition = 'bottom',
|
||||
@@ -103,9 +101,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const UomsTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -114,22 +109,16 @@ const UomsTable = () => {
|
||||
toQueryString: getTableFilterQueryString,
|
||||
} = useTableFilter({
|
||||
initial: {
|
||||
search: searchValue,
|
||||
search: '',
|
||||
},
|
||||
paramMap: {
|
||||
page: 'page',
|
||||
pageSize: 'limit',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'uom-table',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('uoms-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -146,8 +135,7 @@ const UomsTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -31,7 +24,6 @@ import { Warehouse } from '@/types/api/master-data/warehouse';
|
||||
import { WarehouseApi, AreaApi } from '@/services/api/master-data';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
import { useTableFilter } from '@/services/hooks/useTableFilter';
|
||||
import { useUiStore } from '@/stores/ui/ui.store';
|
||||
import {
|
||||
WarehouseFilterSchema,
|
||||
WarehouseFilterType,
|
||||
@@ -120,9 +112,6 @@ const RowOptionsMenu = ({
|
||||
};
|
||||
|
||||
const WarehousesTable = () => {
|
||||
const { searchValue, setSearchValue, setTableState } = useUiStore();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
state: tableFilterState,
|
||||
updateFilter,
|
||||
@@ -141,6 +130,8 @@ const WarehousesTable = () => {
|
||||
areaFilter: 'area_id',
|
||||
activeProjectFlockFilter: 'active_project_flock',
|
||||
},
|
||||
persist: true,
|
||||
storeName: 'warehouses-table',
|
||||
});
|
||||
|
||||
// ===== FILTER MODAL STATE =====
|
||||
@@ -149,27 +140,36 @@ const WarehousesTable = () => {
|
||||
// ===== FORMIK SETUP =====
|
||||
const formik = useFormik<WarehouseFilterType>({
|
||||
initialValues: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
area_id: tableFilterState.areaFilter || null,
|
||||
active_project_flock:
|
||||
tableFilterState.activeProjectFlockFilter === 'true',
|
||||
},
|
||||
validationSchema: WarehouseFilterSchema,
|
||||
onSubmit: (values, { setSubmitting }) => {
|
||||
updateFilter('areaFilter', values.area_id || '');
|
||||
updateFilter('areaFilter', values.area_id || '', true);
|
||||
updateFilter(
|
||||
'activeProjectFlockFilter',
|
||||
values.active_project_flock === true ? 'true' : ''
|
||||
values.active_project_flock === true ? 'true' : '',
|
||||
true
|
||||
);
|
||||
filterModal.closeModal();
|
||||
setSubmitting(false);
|
||||
},
|
||||
onReset: () => {
|
||||
updateFilter('areaFilter', '');
|
||||
updateFilter('activeProjectFlockFilter', '');
|
||||
formik.setFieldValue('active_project_flock', false);
|
||||
});
|
||||
|
||||
const formikResetHandler = () => {
|
||||
updateFilter('areaFilter', '', true);
|
||||
updateFilter('activeProjectFlockFilter', '', true);
|
||||
|
||||
formik.resetForm({
|
||||
values: {
|
||||
area_id: null,
|
||||
active_project_flock: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { setFieldValue } = formik;
|
||||
filterModal.closeModal();
|
||||
};
|
||||
|
||||
// ===== AREA OPTIONS =====
|
||||
const {
|
||||
@@ -243,26 +243,6 @@ const WarehousesTable = () => {
|
||||
formik.validateForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filterModal.open) {
|
||||
const activeProjectFlockValue =
|
||||
tableFilterState.activeProjectFlockFilter === 'true' ? true : false; // Default ke false (Semua Kandang)
|
||||
setFieldValue('active_project_flock', activeProjectFlockValue);
|
||||
}
|
||||
}, [
|
||||
filterModal.open,
|
||||
tableFilterState.activeProjectFlockFilter,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFilter('search', searchValue);
|
||||
}, [searchValue, updateFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableState('warehouses-table', pathname);
|
||||
}, [pathname, setTableState]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const {
|
||||
@@ -281,8 +261,7 @@ const WarehousesTable = () => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const searchChangeHandler: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setSearchValue(e.target.value);
|
||||
updateFilter('search', e.target.value);
|
||||
updateFilter('search', e.target.value, true);
|
||||
};
|
||||
|
||||
const confirmationModalDeleteClickHandler = async () => {
|
||||
@@ -507,7 +486,7 @@ const WarehousesTable = () => {
|
||||
<Icon icon='heroicons:x-mark' width={20} height={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
|
||||
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
||||
<div className='p-4 flex flex-col gap-1.5'>
|
||||
<SelectInput
|
||||
label='Area'
|
||||
@@ -538,10 +517,7 @@ const WarehousesTable = () => {
|
||||
type='button'
|
||||
variant='soft'
|
||||
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
|
||||
onClick={() => {
|
||||
formik.resetForm();
|
||||
filterModal.closeModal();
|
||||
}}
|
||||
onClick={formikResetHandler}
|
||||
>
|
||||
Reset Filter
|
||||
</Button>
|
||||
|
||||
@@ -17,16 +17,10 @@ import {
|
||||
formatVechicleNumber,
|
||||
formatTitleCase,
|
||||
} from '@/lib/helper';
|
||||
import {
|
||||
DailyMarketingRow,
|
||||
DailyMarketingReportResponse,
|
||||
} from '@/types/api/report/marketing';
|
||||
import { DailyMarketingRow } from '@/types/api/report/marketing';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import Button from '@/components/Button';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF';
|
||||
import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { useFormik } from 'formik';
|
||||
@@ -39,8 +33,6 @@ import Modal, { useModal } from '@/components/Modal';
|
||||
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
|
||||
import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton';
|
||||
import { useEffect as useEffectHook } from 'react';
|
||||
import { httpClient } from '@/services/http/client';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import {
|
||||
MARKETING_DATE_FILTER_TYPE_OPTIONS,
|
||||
MARKETING_TYPE_OPTIONS,
|
||||
@@ -284,10 +276,10 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
[dailyMarketings]
|
||||
);
|
||||
|
||||
// ===== EXPORT DATA FETCHER =====
|
||||
const dailyMarketingsExport = useCallback(async (): Promise<
|
||||
DailyMarketingRow[] | null
|
||||
> => {
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
@@ -301,51 +293,13 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by) params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
params.set('page', '1');
|
||||
params.set('limit', '9999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await httpClient<DailyMarketingReportResponse>(
|
||||
`${MarketingReportApi.basePath}${queryString}`
|
||||
);
|
||||
|
||||
if (isResponseError(response)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.data || [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== EXPORT HANDLERS =====
|
||||
const handleExportExcel = useCallback(async () => {
|
||||
setIsExcelExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
|
||||
const period =
|
||||
filterParams.start_date && filterParams.end_date
|
||||
? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}`
|
||||
: undefined;
|
||||
|
||||
await generateDailyMarketingExcel({
|
||||
data: allDataForExport,
|
||||
summaryTotal: summaryTotal,
|
||||
period: period,
|
||||
});
|
||||
await MarketingReportApi.exportDailyMarketingToExcel(params.toString());
|
||||
|
||||
toast.success('Excel berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
@@ -353,34 +307,39 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => {
|
||||
} finally {
|
||||
setIsExcelExportLoading(false);
|
||||
}
|
||||
}, [filterParams, dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
setIsPdfExportLoading(true);
|
||||
try {
|
||||
const allDataForExport = await dailyMarketingsExport();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!allDataForExport || allDataForExport.length === 0) {
|
||||
toast.error('Tidak ada data untuk diekspor.');
|
||||
return;
|
||||
}
|
||||
if (searchValue) params.set('search', searchValue);
|
||||
if (filterParams.area_id) params.set('area_id', filterParams.area_id);
|
||||
if (filterParams.location_id)
|
||||
params.set('location_id', filterParams.location_id);
|
||||
if (filterParams.warehouse_id)
|
||||
params.set('warehouse_id', filterParams.warehouse_id);
|
||||
if (filterParams.customer_id)
|
||||
params.set('customer_id', filterParams.customer_id);
|
||||
if (filterParams.start_date)
|
||||
params.set('start_date', filterParams.start_date);
|
||||
if (filterParams.end_date) params.set('end_date', filterParams.end_date);
|
||||
if (filterParams.filter_by)
|
||||
params.set('filter_by', filterParams.filter_by);
|
||||
if (filterParams.marketing_type)
|
||||
params.set('marketing_type', filterParams.marketing_type);
|
||||
if (filterParams.sort_by) params.set('sort_by', filterParams.sort_by);
|
||||
|
||||
const dailyMarketingReportPdfBlob = await pdf(
|
||||
<DailyMarketingReportPDF data={allDataForExport} total={summaryTotal} />
|
||||
).toBlob();
|
||||
await MarketingReportApi.exportDailyMarketingToPDF(params.toString());
|
||||
|
||||
const dailyMarketingReportPdfUrl = URL.createObjectURL(
|
||||
dailyMarketingReportPdfBlob
|
||||
);
|
||||
window.open(dailyMarketingReportPdfUrl, '_blank');
|
||||
|
||||
toast.success('PDF berhasil dibuat.');
|
||||
toast.success('PDF berhasil dibuat dan diunduh.');
|
||||
} catch {
|
||||
toast.error('Gagal membuat PDF. Silakan coba lagi.');
|
||||
} finally {
|
||||
setIsPdfExportLoading(false);
|
||||
}
|
||||
}, [dailyMarketingsExport, summaryTotal]);
|
||||
}, [filterParams, searchValue]);
|
||||
|
||||
// ===== TAB ACTIONS COMPONENT =====
|
||||
const TabActions = useMemo(() => {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { BaseApiService } from '@/services/api/base';
|
||||
import { httpClientFetcher } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { httpClient, httpClientFetcher } from '@/services/http/client';
|
||||
import {
|
||||
DailyMarketingReport,
|
||||
DailyMarketingReportResponse,
|
||||
} from '@/types/api/report/marketing';
|
||||
import { isResponseError } from '@/lib/api-helper';
|
||||
import { formatDate } from '@/lib/helper';
|
||||
|
||||
export class MarketingReportApiService extends BaseApiService<
|
||||
@@ -29,48 +24,53 @@ export class MarketingReportApiService extends BaseApiService<
|
||||
async exportDailyMarketingToExcel(initialQueryString: string) {
|
||||
const params = new URLSearchParams(initialQueryString);
|
||||
|
||||
params.set('limit', '9999999');
|
||||
params.set('export', 'excel');
|
||||
params.set('page', '1');
|
||||
params.set('limit', '99999999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const dailyMarketingsReport = await httpClientFetcher<
|
||||
BaseApiResponse<DailyMarketingReport>
|
||||
>(`${this.basePath}${queryString}`);
|
||||
|
||||
if (isResponseError(dailyMarketingsReport)) {
|
||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = dailyMarketingsReport.data;
|
||||
|
||||
const formattedRows = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
formattedRows.push({
|
||||
...rows[i],
|
||||
// created_user: rows[i].created_user.name,
|
||||
// created_at: formatDate(rows[i].created_at, 'YYYY-MM-DD'),
|
||||
// updated_at: formatDate(rows[i].updated_at, 'YYYY-MM-DD'),
|
||||
so_date: formatDate(rows[i].so_date, 'YYYY-MM-DD'),
|
||||
realization_date: formatDate(rows[i].realization_date, 'YYYY-MM-DD'),
|
||||
sales: rows[i].sales.name,
|
||||
warehouse: rows[i].warehouse.name,
|
||||
customer: rows[i].customer.name,
|
||||
product: rows[i].product.name,
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `laporan-penjualan-harian-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(formattedRows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'laporan-penjualan-harian');
|
||||
async exportDailyMarketingToPDF(initialQueryString: string) {
|
||||
const params = new URLSearchParams(initialQueryString);
|
||||
|
||||
// triggers download in browser
|
||||
XLSX.writeFile(wb, 'laporan-penjualan-harian.xlsx');
|
||||
} catch {
|
||||
toast.error('Gagal melakukan export penjualan harian! Coba lagi.');
|
||||
}
|
||||
params.set('export', 'pdf');
|
||||
params.set('page', '1');
|
||||
params.set('limit', '99999999999');
|
||||
|
||||
const queryString = `?${params.toString()}`;
|
||||
|
||||
const res = await httpClient<Blob>(`${this.basePath}${queryString}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
const fileName = `laporan-penjualan-harian-${formatDate(Date.now(), 'DD-MM-YYYY')}.pdf`;
|
||||
link.setAttribute('download', fileName);
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import { useTableFilterStore } from '@/stores/table/table-filter.store';
|
||||
import { OptionType } from '@/components/input/SelectInput';
|
||||
|
||||
type TableFilterStateValue =
|
||||
| undefined
|
||||
| null
|
||||
| boolean
|
||||
| string
|
||||
| string[]
|
||||
| number
|
||||
| number[]
|
||||
| OptionType<number>
|
||||
| OptionType<number>[]
|
||||
| OptionType<string>
|
||||
| OptionType<string>[];
|
||||
|
||||
/** Core filter shape (page + pageSize) extended by your custom fields */
|
||||
export type TableFilterState<TExtra extends Record<string, unknown>> = {
|
||||
export type TableFilterState<
|
||||
TExtra extends Record<string, TableFilterStateValue>,
|
||||
> = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
} & TExtra;
|
||||
|
||||
type Action<TExtra extends Record<string, unknown>> =
|
||||
type Action<TExtra extends Record<string, TableFilterStateValue>> =
|
||||
| { type: 'SET_PAGE'; page: number }
|
||||
| { type: 'SET_PAGE_SIZE'; pageSize: number; resetPage?: boolean }
|
||||
| { type: 'SET_FILTERS'; filters: Partial<TExtra>; resetPage?: boolean }
|
||||
@@ -20,7 +36,9 @@ type Action<TExtra extends Record<string, unknown>> =
|
||||
| { type: 'REPLACE_ALL'; next: TableFilterState<TExtra> }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type UseTableFilterOptions<TExtra extends Record<string, unknown>> = {
|
||||
export type UseTableFilterOptions<
|
||||
TExtra extends Record<string, TableFilterStateValue>,
|
||||
> = {
|
||||
/** Initial state; anything you omit falls back to defaults */
|
||||
initial?: Partial<TableFilterState<TExtra>>;
|
||||
/** Called after any state change */
|
||||
@@ -43,9 +61,9 @@ function clampToInt(n: number, min = 1) {
|
||||
return v < min ? min : v;
|
||||
}
|
||||
|
||||
function createInitialState<TExtra extends Record<string, unknown>>(
|
||||
opts: UseTableFilterOptions<TExtra> | undefined
|
||||
): TableFilterState<TExtra> {
|
||||
function createInitialState<
|
||||
TExtra extends Record<string, TableFilterStateValue>,
|
||||
>(opts: UseTableFilterOptions<TExtra> | undefined): TableFilterState<TExtra> {
|
||||
const defaults = {
|
||||
page: 1,
|
||||
pageSize: opts?.defaultPageSize ?? 10,
|
||||
@@ -59,10 +77,22 @@ function createInitialState<TExtra extends Record<string, unknown>>(
|
||||
|
||||
function serializeValue(v: unknown): string | null {
|
||||
if (v === undefined || v === null) return null;
|
||||
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (Array.isArray(v)) return v.map((x) => x ?? '').join(','); // e.g., ids=1,2,3
|
||||
|
||||
if (v instanceof Object && (v as OptionType).value)
|
||||
return String((v as OptionType).value);
|
||||
|
||||
if (Array.isArray(v))
|
||||
return v
|
||||
.map((x) => serializeValue(x))
|
||||
.filter((x) => x !== null)
|
||||
.join(',');
|
||||
|
||||
const t = typeof v;
|
||||
|
||||
if (t === 'string' || t === 'number' || t === 'boolean') return String(v);
|
||||
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
@@ -70,32 +100,16 @@ function serializeValue(v: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// function shallowEqual(a: unknown, b: unknown): boolean {
|
||||
// if (a === b) return true;
|
||||
// if (!a || !b) return false;
|
||||
// const ka = Object.keys(a);
|
||||
// const kb = Object.keys(b);
|
||||
// if (ka.length !== kb.length) return false;
|
||||
// for (const k of ka) if (a[k] !== b[k]) return false;
|
||||
// return true;
|
||||
// }
|
||||
|
||||
function shallowEqual<T extends Record<string, unknown>>(
|
||||
function shallowEqual<T extends TableFilterStateValue>(
|
||||
a: T | undefined | null,
|
||||
b: T | undefined | null
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
const ka = Object.keys(a) as (keyof T)[];
|
||||
const kb = Object.keys(b) as (keyof T)[];
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (const k of ka) if (a[k] !== b[k]) return false;
|
||||
return true;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
options?: UseTableFilterOptions<TExtra>
|
||||
) {
|
||||
export function useTableFilter<
|
||||
TExtra extends Record<string, TableFilterStateValue>,
|
||||
>(options?: UseTableFilterOptions<TExtra>) {
|
||||
if (options?.persist && !options?.storeName) {
|
||||
throw new Error(
|
||||
'storeName is required if persist is true in useTableFilter!'
|
||||
@@ -220,7 +234,9 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
);
|
||||
|
||||
const extras = useMemo(() => {
|
||||
const stateWithExtras = state as TableFilterState<Record<string, unknown>>;
|
||||
const stateWithExtras = state as TableFilterState<
|
||||
Record<string, TableFilterStateValue>
|
||||
>;
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(stateWithExtras).filter(
|
||||
([key]) => key !== 'page' && key !== 'pageSize'
|
||||
@@ -241,10 +257,8 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
/** Build URLSearchParams from current state */
|
||||
const toSearchParams = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
const source = state as Record<string, unknown>;
|
||||
const baseline = options?.omitDefaultsInUrl
|
||||
? (defaults as Record<string, unknown>)
|
||||
: null;
|
||||
const source = state as Record<string, TableFilterStateValue>;
|
||||
const baseline = options?.omitDefaultsInUrl ? defaults : null;
|
||||
const excludedKeys = new Set<string>(
|
||||
(options?.excludeKeysFromUrl as string[] | undefined) ?? []
|
||||
);
|
||||
@@ -255,13 +269,7 @@ export function useTableFilter<TExtra extends Record<string, unknown>>(
|
||||
const value = source[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (
|
||||
baseline &&
|
||||
shallowEqual(
|
||||
value as Record<string, unknown>,
|
||||
baseline[key] as Record<string, unknown>
|
||||
)
|
||||
) {
|
||||
if (baseline && shallowEqual(value, baseline[key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user