Merge branch 'development' into 'production'

Revert "fixing devops"

See merge request mbugroup/lti-web-client!449
This commit is contained in:
Adnan Zahir
2026-04-30 09:54:39 +07:00
21 changed files with 559 additions and 594 deletions
+156
View File
@@ -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(() => {
+41 -41
View File
@@ -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();
}
}
+48 -40
View File
@@ -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;
}