Merge branch 'feat/marketing-table-order' into 'development'

[FEAT/FE] Marketing Table Order

See merge request mbugroup/lti-web-client!465
This commit is contained in:
Rivaldi A N S
2026-05-08 09:30:39 +00:00
2 changed files with 136 additions and 71 deletions
+39
View File
@@ -161,6 +161,45 @@ const handleFilterLocationChange = useCallback(
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/` - `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.) - Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)
## Server-side sorting pattern
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
**Four-part wiring:**
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
```ts
initial: { sort_by: '', order_by: '' }
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
```
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
```ts
useEffect(() => {
if (sorting.length > 0) {
updateFilter('sort_by', sorting[0].id, true);
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '');
updateFilter('order_by', '');
}
}, [sorting]);
```
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
**Pass `sorting` / `setSorting` to `<Table>`:**
```tsx
<Table sorting={sorting} setSorting={setSorting} ... />
```
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
## Server-side file export pattern ## 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. 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.
@@ -1,6 +1,5 @@
'use client'; 'use client';
import axios from 'axios';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CheckboxInput from '@/components/input/CheckboxInput'; import CheckboxInput from '@/components/input/CheckboxInput';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
@@ -27,7 +26,13 @@ import {
MarketingFilter, MarketingFilter,
} from '@/types/api/marketing/marketing'; } from '@/types/api/marketing/marketing';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { CellContext, ColumnDef, Row } from '@tanstack/react-table'; import {
CellContext,
ColumnDef,
Row,
SortingState,
Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -198,6 +203,8 @@ const MarketingTable = () => {
project_flock_name: '', project_flock_name: '',
project_flock_kandang_id: '', project_flock_kandang_id: '',
project_flock_kandang_name: '', project_flock_kandang_name: '',
sort_by: '',
order_by: '',
}, },
paramMap: { paramMap: {
page: 'page', page: 'page',
@@ -207,6 +214,8 @@ const MarketingTable = () => {
customer_id: 'customer_id', customer_id: 'customer_id',
project_flock_id: 'project_flock_id', project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id', project_flock_kandang_id: 'project_flock_kandang_id',
sort_by: 'sort_by',
order_by: 'sort_order',
}, },
excludeKeysFromUrl: [ excludeKeysFromUrl: [
'product_names', 'product_names',
@@ -220,6 +229,26 @@ const MarketingTable = () => {
storeName: 'marketing-table', storeName: 'marketing-table',
}); });
const sorting: SortingState = tableFilterState.sort_by
? [
{
id: tableFilterState.sort_by,
desc: tableFilterState.order_by === 'desc',
},
]
: [];
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
if (next.length > 0) {
updateFilter('sort_by', next[0].id, true);
updateFilter('order_by', next[0].desc ? 'desc' : 'asc', true);
} else {
updateFilter('sort_by', '', true);
updateFilter('order_by', '', true);
}
};
// ===== FETCH DATA ===== // ===== FETCH DATA =====
const { const {
data: marketing, data: marketing,
@@ -359,55 +388,50 @@ const MarketingTable = () => {
? 'DELIVERY_ORDER' ? 'DELIVERY_ORDER'
: null; : null;
const marketingFilterInitialValues = useMemo(() => { const productIds = tableFilterState.product_ids
const productIds = tableFilterState.product_ids ? tableFilterState.product_ids
? tableFilterState.product_ids .split(',')
.split(',') .map((item) => item.trim())
.map((item) => item.trim()) .filter(Boolean)
.filter(Boolean) : [];
: [];
const productLabels = tableFilterState.product_names const productLabels = tableFilterState.product_names
? tableFilterState.product_names ? tableFilterState.product_names
.split(',') .split(',')
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
: []; : [];
return { const marketingFilterInitialValues = {
product_ids: productIds.map((value, idx) => ({ product_ids: productIds.map((value, idx) => ({
value: Number(value), value: Number(value),
label: productLabels[idx] || '-', label: productLabels[idx] || '-',
})), })),
status: tableFilterState.status status: tableFilterState.status
? { ? {
value: tableFilterState.status, value: tableFilterState.status,
label: tableFilterState.status_name, label: tableFilterState.status_name,
} }
: null, : null,
customer: tableFilterState.customer_id
customer: tableFilterState.customer_id ? {
? { value: Number(tableFilterState.customer_id),
value: Number(tableFilterState.customer_id), label: tableFilterState.customer_name,
label: tableFilterState.customer_name, }
} : null,
: null, project_flock: tableFilterState.project_flock_id
? {
project_flock: tableFilterState.project_flock_id value: Number(tableFilterState.project_flock_id),
? { label: tableFilterState.project_flock_name,
value: Number(tableFilterState.project_flock_id), }
label: tableFilterState.project_flock_name, : null,
} project_flock_kandang: tableFilterState.project_flock_kandang_id
: null, ? {
value: Number(tableFilterState.project_flock_kandang_id),
project_flock_kandang: tableFilterState.project_flock_kandang_id label: tableFilterState.project_flock_kandang_name,
? { }
value: Number(tableFilterState.project_flock_kandang_id), : null,
label: tableFilterState.project_flock_kandang_name, };
}
: null,
};
}, [tableFilterState]);
const approveMarketingHandler = async (notes: string) => { const approveMarketingHandler = async (notes: string) => {
if (idsToProcess.length === 0) { if (idsToProcess.length === 0) {
@@ -542,27 +566,29 @@ const MarketingTable = () => {
setIsLoadingExportingToExcel(false); setIsLoadingExportingToExcel(false);
}; };
const resetExportProgressForm = useCallback(() => { const resetExportProgressForm = () => {
setExportProgressStartDate(''); setExportProgressStartDate('');
setExportProgressEndDate(''); setExportProgressEndDate('');
}, []); };
const exportProgressStartDateChangeHandler: ChangeEventHandler<HTMLInputElement> = const exportProgressStartDateChangeHandler: ChangeEventHandler<
useCallback((e) => { HTMLInputElement
setExportProgressStartDate(e.target.value); > = (e) => {
}, []); setExportProgressStartDate(e.target.value);
};
const exportProgressEndDateChangeHandler: ChangeEventHandler<HTMLInputElement> = const exportProgressEndDateChangeHandler: ChangeEventHandler<
useCallback((e) => { HTMLInputElement
setExportProgressEndDate(e.target.value); > = (e) => {
}, []); setExportProgressEndDate(e.target.value);
};
const exportProgressInputToExcelClickHandler = useCallback(() => { const exportProgressInputToExcelClickHandler = () => {
resetExportProgressForm(); resetExportProgressForm();
exportProgressInputModal.openModal(); exportProgressInputModal.openModal();
}, [exportProgressInputModal, resetExportProgressForm]); };
const submitExportProgressInputHandler = useCallback(async () => { const submitExportProgressInputHandler = async () => {
if (!exportProgressStartDate || !exportProgressEndDate) { if (!exportProgressStartDate || !exportProgressEndDate) {
return; return;
} }
@@ -585,12 +611,7 @@ const MarketingTable = () => {
} finally { } finally {
setIsExportProgressLoading(false); setIsExportProgressLoading(false);
} }
}, [ };
exportProgressEndDate,
exportProgressInputModal,
exportProgressStartDate,
resetExportProgressForm,
]);
const columns = useMemo<ColumnDef<Marketing>[]>(() => { const columns = useMemo<ColumnDef<Marketing>[]>(() => {
return [ return [
@@ -656,7 +677,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'so_do_number', accessorKey: 'so_number',
header: 'No. Order', header: 'No. Order',
cell: (props) => { cell: (props) => {
return props.row.original.do_number return props.row.original.do_number
@@ -672,7 +693,7 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'approval.step_name', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: (props) => { cell: (props) => {
const approval = props.row.original.latest_approval; const approval = props.row.original.latest_approval;
@@ -707,10 +728,12 @@ const MarketingTable = () => {
}, },
}, },
{ {
accessorKey: 'customer.name', accessorKey: 'customer',
header: 'Customer', header: 'Customer',
cell: (props) => props.row.original.customer.name,
}, },
{ {
accessorKey: 'grand_total',
accessorFn: (row) => accessorFn: (row) =>
row.sales_order row.sales_order
?.map((product) => product.total_price) ?.map((product) => product.total_price)
@@ -727,6 +750,7 @@ const MarketingTable = () => {
{ {
accessorKey: 'marketing_products.length', accessorKey: 'marketing_products.length',
header: 'Product Details', header: 'Product Details',
enableSorting: false,
cell: (props) => { cell: (props) => {
if (props?.row?.original?.sales_order?.length) { if (props?.row?.original?.sales_order?.length) {
if (props?.row?.original?.sales_order?.length > 1) { if (props?.row?.original?.sales_order?.length > 1) {
@@ -949,6 +973,8 @@ const MarketingTable = () => {
columns={columns} columns={columns}
pageSize={tableFilterState.pageSize} pageSize={tableFilterState.pageSize}
page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1} page={isResponseSuccess(marketing) ? marketing?.meta?.page : 1}
sorting={sorting}
setSorting={handleSortingChange}
totalItems={ totalItems={
isResponseSuccess(marketing) isResponseSuccess(marketing)
? marketing?.meta?.total_results ? marketing?.meta?.total_results