diff --git a/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx index c930970e..6b641358 100644 --- a/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/PurchasesPerSupplierTab.tsx @@ -1,23 +1,69 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; +import { ChangeEventHandler } from 'react'; +import useSWR from 'swr'; import Card from '@/components/Card'; -import SelectInput, { useSelect } from '@/components/input/SelectInput'; +import SelectInput, { + useSelect, + OptionType, +} from '@/components/input/SelectInput'; import DateInput from '@/components/input/DateInput'; import { AreaApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data'; import { ProductApi } from '@/services/api/master-data'; +import { LogisticService } from '@/services/api/logistic'; import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; import { formatCurrency, formatDate } from '@/lib/helper'; +import { + SupplierWithItems, + LogisticPurchasePerSupplierItems, +} from '@/types/api/report/logistic-stock'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; + +interface Totals { + totalQty: number; + totalPrice: number; + totalPurchaseAmount: number; + totalTransport: number; + totalValueTransport: number; + totalJumlah: number; +} const PurchasesPerSupplierTab = () => { - const [selectedArea, setSelectedArea] = useState(null); - const [selectedSupplier, setSelectedSupplier] = useState(null); - const [selectedProduct, setSelectedProduct] = useState(null); const [dataType, setDataType] = useState<'received_date' | 'po_date'>( 'received_date' ); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); + + // ===== TABLE FILTER STATE ===== + const { + state: tableFilterState, + updateFilter, + toQueryString: getTableFilterQueryString, + } = useTableFilter({ + initial: { + area_id: '', + supplier_id: '', + product_id: '', + received_date: '', + po_date: '', + start_date: '', + end_date: '', + }, + paramMap: { + page: 'page', + pageSize: 'limit', + }, + }); + + const updateDataType = useCallback( + (newDataType: 'received_date' | 'po_date') => { + setDataType(newDataType); + updateFilter('received_date', ''); + updateFilter('po_date', ''); + }, + [updateFilter] + ); const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( AreaApi.basePath, @@ -40,147 +86,77 @@ const PurchasesPerSupplierTab = () => { [] ); - const data = useMemo( - () => [ - { - id: 1, - supplier: 'PT. RAJAWALI MITRA PAKANINDO', - items: [ - { - id: 1, - received_date: '2025-09-26', - po_date: '2025-09-30', - po_number: 'PO-MBU-00670', - product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', - destination_warehouse: 'GUDANG CIMARAGAS 1', - qty: 5, - price: 45000, - purchase_amount: 225000, - transport: 0, - value_transport: 0, - total: 225000, - expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', - travel_number: '-', - }, - { - id: 2, - received_date: '2025-09-30', - po_date: '2025-09-30', - po_number: 'PO-MBU-00670', - product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', - destination_warehouse: 'GUDANG CIMARAGAS 2', - qty: 5, - price: 45000, - purchase_amount: 225000, - transport: 0, - value_transport: 0, - total: 225000, - expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', - travel_number: '-', - }, - { - id: 3, - received_date: '2025-09-30', - po_date: '2025-09-30', - po_number: 'PO-MBU-00670', - product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', - destination_warehouse: 'GUDANG CIMARAGAS 3', - qty: 5, - price: 45000, - purchase_amount: 225000, - transport: 0, - value_transport: 0, - total: 225000, - expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', - travel_number: '-', - }, - { - id: 4, - received_date: '2025-09-30', - po_date: '2025-09-30', - po_number: 'PO-MBU-00670', - product_name: 'KAPORIT BESAR (TCCA) 200 GR @1 KG', - destination_warehouse: 'GUDANG CIMARAGAS 4', - qty: 5, - price: 45000, - purchase_amount: 225000, - transport: 0, - value_transport: 0, - total: 225000, - expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', - travel_number: '-', - }, - { - id: 5, - received_date: '2025-09-04', - po_date: '2025-09-30', - po_number: 'PO-MBU-00606', - product_name: 'DESINFEKTAN C 100 @20L', - destination_warehouse: 'GUDANG MANDALAWANGI 1', - qty: 1, - price: 800000, - purchase_amount: 800000, - transport: 0, - value_transport: 0, - total: 800000, - expedition_vendor_name: 'PT. RAJAWALI MITRA PAKANINDO', - travel_number: '-', - }, - ], - }, - { - id: 2, - supplier: 'Supplier B', - items: [ - { - id: 6, - received_date: '2024-01-18', - po_date: '2024-01-13', - po_number: 'PO-2024-004', - product_name: 'Produk D', - destination_warehouse: 'Gudang Pusat', - qty: 200, - price: 25000, - purchase_amount: 5000000, - transport: 200000, - value_transport: 200000, - total: 5200000, - expedition_vendor_name: 'Ekspedisi GHI', - travel_number: 'SJ-004', - }, - ], - }, - { - id: 3, - supplier: 'Supplier C', - items: [ - { - id: 7, - received_date: '2024-01-20', - po_date: '2024-01-15', - po_number: 'PO-2024-006', - product_name: 'Produk F', - destination_warehouse: 'Gudang Cabang', - qty: 80, - price: 55000, - purchase_amount: 4400000, - transport: 80000, - value_transport: 80000, - total: 4480000, - expedition_vendor_name: 'Ekspedisi MNO', - travel_number: 'SJ-006', - }, - ], - }, - ], - [] + const areaChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + updateFilter('area_id', newVal?.value ? String(newVal.value) : ''); + }, + [updateFilter] ); - // TODO START - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getTableColumns = (totals: any) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tableColumns: ColumnDef[] = [ + const supplierChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + updateFilter('supplier_id', newVal?.value ? String(newVal.value) : ''); + }, + [updateFilter] + ); + + const productChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + updateFilter('product_id', newVal?.value ? String(newVal.value) : ''); + }, + [updateFilter] + ); + + const dataTypeChangeHandler = useCallback( + (val: OptionType | OptionType[] | null) => { + const newVal = val as OptionType; + updateDataType( + (newVal?.value as 'received_date' | 'po_date') || 'received_date' + ); + }, + [updateDataType] + ); + + const startDateChangeHandler = useCallback< + ChangeEventHandler + >( + (e) => { + const val = e.target.value; + updateFilter('start_date', val || ''); + if (val && dataType) { + updateFilter(dataType, val); + } + }, + [updateFilter, dataType] + ); + + const endDateChangeHandler = useCallback< + ChangeEventHandler + >( + (e) => { + const val = e.target.value; + updateFilter('end_date', val || ''); + }, + [updateFilter] + ); + + // ===== DATA FETCHING ===== + const { data: response, isLoading } = useSWR( + `${LogisticService.basePath}/purchase-supplier${getTableFilterQueryString()}`, + LogisticService.getAllFetcher + ); + + const data: SupplierWithItems[] = isResponseSuccess(response) + ? (response?.data as unknown as SupplierWithItems[]) + : []; + + const getTableColumns = ( + totals: Totals + ): ColumnDef[] => { + const tableColumns: ColumnDef[] = [ { id: 'no', header: 'No', @@ -191,38 +167,53 @@ const PurchasesPerSupplierTab = () => { id: 'received_date', header: 'Tanggal Terima', accessorKey: 'received_date', - cell: (props) => formatDate(props.getValue() as string, 'DD MMM YYYY'), + cell: (props) => { + const value = props.row.original.received_date; + return formatDate(value, 'DD MMM YYYY'); + }, }, { id: 'po_date', header: 'Tanggal PO', accessorKey: 'po_date', - cell: (props) => formatDate(props.getValue() as string, 'DD MMM YYYY'), + cell: (props) => { + const value = props.row.original.po_date; + return formatDate(value, 'DD MMM YYYY'); + }, }, { id: 'po_number', header: 'No. Referensi', accessorKey: 'po_number', - cell: (props) => props.getValue() || '-', + cell: (props) => { + const value = props.row.original.po_number; + return value || '-'; + }, }, { id: 'product_name', header: 'Nama Produk', - accessorKey: 'product_name', - cell: (props) => props.getValue() || '-', + accessorKey: 'product.name', + cell: (props) => { + const product = props.row.original.product; + return product?.name || '-'; + }, }, { id: 'destination_warehouse', header: 'Tujuan', accessorKey: 'destination_warehouse', - cell: (props) => props.getValue() || '-', + cell: (props) => { + const value = props.row.original.destination_warehouse; + return value || '-'; + }, }, { id: 'qty', header: 'QTY', accessorKey: 'qty', cell: (props) => { - const value = props.getValue() as number; + const value = props.row.original.qty; return
{value.toLocaleString()}
; }, footer: () => ( @@ -236,7 +227,7 @@ const PurchasesPerSupplierTab = () => { header: 'Harga Beli (Rp)', accessorKey: 'price', cell: (props) => { - const value = props.getValue() as number; + const value = props.row.original.price; return
{formatCurrency(value)}
; }, footer: () => ( @@ -248,9 +239,9 @@ const PurchasesPerSupplierTab = () => { { id: 'purchase_amount', header: 'Value Harga Beli (Rp)', - accessorKey: 'purchase_amount', cell: (props) => { - const value = props.getValue() as number; + const item = props.row.original; + const value = (item.price || 0) * (item.qty || 0); return
{formatCurrency(value)}
; }, footer: () => ( @@ -262,9 +253,9 @@ const PurchasesPerSupplierTab = () => { { id: 'transport', header: 'Transport (Rp)', - accessorKey: 'transport', + accessorKey: 'transport_per_item', cell: (props) => { - const value = props.getValue() as number; + const value = props.row.original.transport_per_item; return
{formatCurrency(value)}
; }, footer: () => ( @@ -276,9 +267,9 @@ const PurchasesPerSupplierTab = () => { { id: 'value_transport', header: 'Value Transport (Rp)', - accessorKey: 'value_transport', + accessorKey: 'transport_total', cell: (props) => { - const value = props.getValue() as number; + const value = props.row.original.transport_total; return
{formatCurrency(value)}
; }, footer: () => ( @@ -290,9 +281,10 @@ const PurchasesPerSupplierTab = () => { { id: 'total', header: 'Jumlah (Rp)', - accessorKey: 'total', cell: (props) => { - const value = props.getValue() as number; + const item = props.row.original; + const value = + (item.price || 0) * (item.qty || 0) + (item.transport_total || 0); return
{formatCurrency(value)}
; }, footer: () => ( @@ -305,66 +297,88 @@ const PurchasesPerSupplierTab = () => { id: 'expedition_vendor_name', header: 'Ekspedisi', accessorKey: 'expedition_vendor_name', - cell: (props) => props.getValue() || '-', + cell: (props) => { + const value = props.row.original.expedition_vendor_name; + return value || '-'; + }, }, { id: 'travel_number', header: 'Surat Jalan', accessorKey: 'travel_number', - cell: (props) => props.getValue() || '-', + cell: (props) => { + const value = props.row.original.travel_number; + return value || '-'; + }, }, ]; return tableColumns; }; return ( - <> +
-
+
{/* TODO START */} option.value === selectedArea) || - null + tableFilterState.area_id + ? areaOptions.find( + (option) => + option.value === Number(tableFilterState.area_id) + ) || null + : null } - // @ts-expect-error TS2345 - onChange={(val) => setSelectedArea(val?.value || null)} + onChange={areaChangeHandler} isLoading={isLoadingAreas} isClearable + className={{ + wrapper: 'col-span-12 sm:col-span-4', + }} /> option.value === selectedSupplier - ) || null + tableFilterState.supplier_id + ? supplierOptions.find( + (option) => + option.value === Number(tableFilterState.supplier_id) + ) || null + : null } - // @ts-expect-error TS2345 - onChange={(val) => setSelectedSupplier(val?.value || null)} + onChange={supplierChangeHandler} isLoading={isLoadingSuppliers} isClearable + className={{ + wrapper: 'col-span-12 sm:col-span-4', + }} /> option.value === selectedProduct - ) || null + tableFilterState.product_id + ? productOptions.find( + (option) => + option.value === Number(tableFilterState.product_id) + ) || null + : null } - // @ts-expect-error TS2345 - onChange={(val) => setSelectedProduct(val?.value || null)} + onChange={productChangeHandler} isLoading={isLoadingProducts} isClearable + className={{ + wrapper: 'col-span-12 sm:col-span-4', + }} /> { dataTypeOptions?.find((option) => option.value === dataType) || null } - // @ts-expect-error TS2345 - onChange={(val) => setDataType(val?.value || 'received_date')} + onChange={dataTypeChangeHandler} isLoading={false} isClearable={false} + className={{ + wrapper: 'col-span-12 sm:col-span-4', + }} /> {/* TODO END */}
- {data.length === 0 ? ( + {isLoading ? ( +
Memuat data...
+ ) : data.length === 0 ? (
Tidak ada data untuk ditampilkan.
@@ -409,30 +431,30 @@ const PurchasesPerSupplierTab = () => { 0 ); const totalPrice = supplier.items.reduce( - (sum, item) => sum + (item.price || 0), - 0 - ); - const totalPurchaseAmount = supplier.items.reduce( - (sum, item) => sum + (item.purchase_amount || 0), + (sum, item) => sum + (item.price || 0) * (item.qty || 0), 0 ); const totalTransport = supplier.items.reduce( - (sum, item) => sum + (item.transport || 0), + (sum, item) => + sum + (item.transport_per_item || 0) * (item.qty || 0), 0 ); const totalValueTransport = supplier.items.reduce( - (sum, item) => sum + (item.value_transport || 0), + (sum, item) => sum + (item.transport_total || 0), 0 ); const totalJumlah = supplier.items.reduce( - (sum, item) => sum + (item.total || 0), + (sum, item) => + sum + + (item.price || 0) * (item.qty || 0) + + (item.transport_total || 0), 0 ); const totals = { totalQty, totalPrice, - totalPurchaseAmount, + totalPurchaseAmount: totalPrice, totalTransport, totalValueTransport, totalJumlah, @@ -444,7 +466,7 @@ const PurchasesPerSupplierTab = () => { return ( { pageSize={10} renderFooter={supplier.items.length > 0} className={{ - containerClassName: 'mb-0!', + containerClassName: 'w-full', tableWrapperClassName: 'overflow-x-auto mt-4', tableClassName: 'w-full table-auto text-sm', headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', @@ -470,7 +492,6 @@ const PurchasesPerSupplierTab = () => { footerRowClassName: 'border-t-2 border-gray-300', footerColumnClassName: 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', - paginationClassName: 'hidden', }} /> @@ -478,7 +499,7 @@ const PurchasesPerSupplierTab = () => { }) )} - +
); }; diff --git a/src/services/api/logistic.ts b/src/services/api/logistic.ts index 41c25135..27fbb6ea 100644 --- a/src/services/api/logistic.ts +++ b/src/services/api/logistic.ts @@ -2,6 +2,12 @@ import { BaseApiService } from '@/services/api/base'; import { BaseApiResponse } from '@/types/api/api-general'; import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock'; +const baseLogisticApi = new BaseApiService< + LogisticPurchasePerSupplierReport, + unknown, + unknown +>('/reports'); + export class LogisticApi extends BaseApiService< LogisticPurchasePerSupplierReport, unknown, @@ -37,4 +43,33 @@ export class LogisticApi extends BaseApiService< } } -export const LogisticService = new LogisticApi('/reports'); +export const LogisticService = { + basePath: baseLogisticApi.basePath, + header: baseLogisticApi.header, + getAllFetcher: baseLogisticApi.getAllFetcher.bind(baseLogisticApi), + getSingle: baseLogisticApi.getSingle.bind(baseLogisticApi), + create: baseLogisticApi.create.bind(baseLogisticApi), + update: baseLogisticApi.update.bind(baseLogisticApi), + delete: baseLogisticApi.delete.bind(baseLogisticApi), + customRequest: baseLogisticApi.customRequest.bind(baseLogisticApi), + + // Custom method for specific endpoint + getLogisticStockReport: ( + area_id?: number, + supplier_id?: number, + product_id?: number, + received_date?: string, + po_date?: string, + start_date?: string, + end_date?: string + ) => + new LogisticApi().getLogisticStockReport( + area_id, + supplier_id, + product_id, + received_date, + po_date, + start_date, + end_date + ), +}; diff --git a/src/types/api/report/logistic-stock.d.ts b/src/types/api/report/logistic-stock.d.ts index 27193186..a275898e 100644 --- a/src/types/api/report/logistic-stock.d.ts +++ b/src/types/api/report/logistic-stock.d.ts @@ -1,7 +1,7 @@ import { BaseMetadata } from '@/types/api/api-general'; import { Supplier } from '@/types/api/supplier/supplier'; import { Product } from '@/types/api/product/product'; -import { Area } from '@types/api/area/area'; +import { Area } from '@/types/api/area/area'; export type LogisticPurchasePerSupplierItems = { id: number; @@ -20,11 +20,12 @@ export type LogisticPurchasePerSupplierItems = { travel_number: string; }; -export type BaseLogisticPurchasePerSupplierReport = { +export type SupplierWithItems = { id: number; supplier: Supplier; - items: LogisticStockItems[]; + items: LogisticPurchasePerSupplierItems[]; }; -export type LogisticPurchasePerSupplierReport = BaseMetadata & - BaseLogisticStockReport; +export type LogisticPurchasePerSupplierReport = BaseMetadata & { + data: SupplierWithItems[]; +};