From 4e5745d23765d96963e5418b6cbf30412815ad2c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 11 Feb 2026 15:53:32 +0700 Subject: [PATCH 01/36] refactor(FE): Add tab state management and skeleton for PurchasesPerSupplierTab --- .../logistic-stock/LogisticStockTabs.tsx | 23 +- .../skeleton/PurchasePerSupplierSkeleton.tsx | 37 + .../tab/PurchasesPerSupplierTab.tsx | 994 +++++++++++------- .../logistic-stock-tab.store.ts | 51 + 4 files changed, 712 insertions(+), 393 deletions(-) create mode 100644 src/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton.tsx create mode 100644 src/stores/logistic-stock-tab/logistic-stock-tab.store.ts diff --git a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx index 1e2d2824..a7b844f3 100644 --- a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx +++ b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx @@ -1,14 +1,19 @@ 'use client'; +import { useState } from 'react'; import Tabs from '@/components/Tabs'; import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab'; +import { useLogisticStockTabStore } from '@/stores/logistic-stock-tab/logistic-stock-tab.store'; const LogisticStockTabs = () => { + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useLogisticStockTabStore((state) => state.tabActions); + const tabs = [ { id: '1', label: 'Rekapitulasi Pembelian Per Supplier', - content: , + content: , }, // { // id: '2', @@ -23,8 +28,20 @@ const LogisticStockTabs = () => { ]; return ( -
- +
+
); }; diff --git a/src/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton.tsx b/src/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton.tsx new file mode 100644 index 00000000..a5268b2f --- /dev/null +++ b/src/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton.tsx @@ -0,0 +1,37 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { LogisticPurchasePerSupplierReport } from '@/types/api/report/logistic-stock'; +import { ColumnDef } from '@tanstack/react-table'; + +const PurchasePerSupplierSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+ +
+ +
+ + ); +}; + +export default PurchasePerSupplierSkeleton; diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index e1659470..4176e8ba 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -1,11 +1,9 @@ -import { useState, useMemo, useCallback } from 'react'; -import { ChangeEventHandler } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import useSWR from 'swr'; import Card from '@/components/Card'; -import SelectInput, { - useSelect, - OptionType, -} from '@/components/input/SelectInput'; +import { useSelect, OptionType } from '@/components/input/SelectInput'; +import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; +import SelectInputRadio from '@/components/input/SelectInputRadio'; import DateInput from '@/components/input/DateInput'; import { AreaApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data'; @@ -14,24 +12,30 @@ import { ProductCategoryApi } from '@/services/api/master-data'; import { LogisticApi } from '@/services/api/report/logistic-stock'; import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; -import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper'; import { LogisticPurchasePerSupplierReport, LogisticPurchasePerSupplierSummary, } from '@/types/api/report/logistic-stock'; import { isResponseSuccess } from '@/lib/api-helper'; -import { useTableFilter } from '@/services/hooks/useTableFilter'; -import Pagination from '@/components/Pagination'; import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; import MenuItem from '@/components/menu/MenuItem'; import Menu from '@/components/menu/Menu'; +import Modal from '@/components/Modal'; +import { useModal } from '@/components/Modal'; import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF'; import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX'; +import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; +import { useLogisticStockTabStore } from '@/stores/logistic-stock-tab/logistic-stock-tab.store'; -const PurchasesPerSupplierTab = () => { +interface PurchasesPerSupplierTabProps { + tabId: string; +} + +const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); @@ -39,31 +43,14 @@ const PurchasesPerSupplierTab = () => { // ===== PAGINATION STATE ===== const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize] = useState(10); // ===== SUBMISSION STATE ===== const [isSubmitted, setIsSubmitted] = useState(false); - // ===== TABLE FILTER STATE ===== - const { state: tableFilterState, updateFilter } = useTableFilter({ - initial: { - area_id: [] as string[], - supplier_id: [] as string[], - product_id: [] as string[], - product_category_id: [] as string[], - received_date: '', - po_date: '', - start_date: '', - end_date: '', - sort_by: '', - filter_by: 'received_date', - }, - paramMap: { - page: 'page', - pageSize: 'limit', - }, - }); + const filterModal = useModal(); + // ===== OPTIONS (Declare before filter state) ===== const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( AreaApi.basePath, 'id', @@ -100,117 +87,232 @@ const PurchasesPerSupplierTab = () => { [] ); - const areaChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'area_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); - }, - [updateFilter] - ); + // ===== APPLIED FILTER STATE (Yang sudah di-apply) ===== + const [appliedFilterArea, setAppliedFilterArea] = useState< + typeof areaOptions + >([]); + const [appliedFilterSupplier, setAppliedFilterSupplier] = useState< + typeof supplierOptions + >([]); + const [appliedFilterProduct, setAppliedFilterProduct] = useState< + typeof productOptions + >([]); + const [appliedFilterProductCategory, setAppliedFilterProductCategory] = + useState([]); + const [appliedFilterByType, setAppliedFilterByType] = useState< + (typeof dataTypeOptions)[0] | null + >(null); + const [appliedFilterSortBy, setAppliedFilterSortBy] = useState< + (typeof sortByOptions)[0] | null + >(null); + const [appliedFilterStartDate, setAppliedFilterStartDate] = useState(''); + const [appliedFilterEndDate, setAppliedFilterEndDate] = useState(''); + const [dateErrorShown, setDateErrorShown] = useState(false); + const [hasDateError, setHasDateError] = useState(false); - const supplierChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'supplier_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); - }, - [updateFilter] + // ===== PENDING FILTER STATE (Yang ada di modal, belum di-apply) ===== + const [filterArea, setFilterArea] = useState([]); + const [filterSupplier, setFilterSupplier] = useState( + [] ); + const [filterProduct, setFilterProduct] = useState([]); + const [filterProductCategory, setFilterProductCategory] = useState< + typeof productCategoryOptions + >([]); + const [filterByType, setFilterByType] = useState< + (typeof dataTypeOptions)[0] | null + >(null); + const [filterSortBy, setFilterSortBy] = useState< + (typeof sortByOptions)[0] | null + >(null); + const [filterStartDate, setFilterStartDate] = useState(''); + const [filterEndDate, setFilterEndDate] = useState(''); - const productChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'product_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); - }, - [updateFilter] - ); + // ===== FILTER HANDLERS ===== + const handleFilterModalOpen = useCallback(() => { + setFilterArea(appliedFilterArea); + setFilterSupplier(appliedFilterSupplier); + setFilterProduct(appliedFilterProduct); + setFilterProductCategory(appliedFilterProductCategory); + setFilterByType(appliedFilterByType); + setFilterSortBy(appliedFilterSortBy); + setFilterStartDate(appliedFilterStartDate); + setFilterEndDate(appliedFilterEndDate); + filterModal.openModal(); + }, [ + filterModal, + appliedFilterArea, + appliedFilterSupplier, + appliedFilterProduct, + appliedFilterProductCategory, + appliedFilterByType, + appliedFilterSortBy, + appliedFilterStartDate, + appliedFilterEndDate, + ]); - const productCategoryChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'product_category_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const dataTypeChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const newVal = val as OptionType; - const filterValue = - (newVal?.value as 'received_date' | 'po_date') || 'received_date'; - updateFilter('filter_by', filterValue); - updateFilter('received_date', ''); - updateFilter('po_date', ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const sortByHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const newVal = val as OptionType; - const sortValue = (newVal?.value as 'ASC' | 'DESC') || 'ASC'; - updateFilter('sort_by', sortValue); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const startDateChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - updateFilter('start_date', val || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const endDateChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - updateFilter('end_date', val || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const resetFilters = useCallback(() => { - updateFilter('area_id', []); - updateFilter('supplier_id', []); - updateFilter('product_id', []); - updateFilter('product_category_id', []); - updateFilter('received_date', ''); - updateFilter('po_date', ''); - updateFilter('start_date', ''); - updateFilter('end_date', ''); - updateFilter('sort_by', ''); - updateFilter('filter_by', 'received_date'); + const handleResetFilters = useCallback(() => { setIsSubmitted(false); - }, [updateFilter]); + setFilterArea([]); + setFilterSupplier([]); + setFilterProduct([]); + setFilterProductCategory([]); + setFilterByType(null); + setFilterSortBy(null); + setFilterStartDate(''); + setFilterEndDate(''); + setAppliedFilterArea([]); + setAppliedFilterSupplier([]); + setAppliedFilterProduct([]); + setAppliedFilterProductCategory([]); + setAppliedFilterByType(null); + setAppliedFilterSortBy(null); + setAppliedFilterStartDate(''); + setAppliedFilterEndDate(''); + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }, [dateErrorShown]); - const handleSubmit = useCallback(() => { + const handleApplyFilters = useCallback(() => { + setAppliedFilterArea(filterArea); + setAppliedFilterSupplier(filterSupplier); + setAppliedFilterProduct(filterProduct); + setAppliedFilterProductCategory(filterProductCategory); + setAppliedFilterByType(filterByType); + setAppliedFilterSortBy(filterSortBy); + setAppliedFilterStartDate(filterStartDate); + setAppliedFilterEndDate(filterEndDate); setIsSubmitted(true); setCurrentPage(1); - }, []); + filterModal.closeModal(); + }, [ + filterModal, + filterArea, + filterSupplier, + filterProduct, + filterProductCategory, + filterByType, + filterSortBy, + filterStartDate, + filterEndDate, + ]); + + const handleStartDateChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setFilterStartDate(value); + + if (value && filterEndDate) { + const startDate = new Date(value); + const endDateObj = new Date(filterEndDate); + + if (endDateObj < startDate) { + setHasDateError(true); + if (!dateErrorShown) { + toast.error('Tanggal akhir tidak boleh masa lampau', { + duration: Infinity, + }); + setDateErrorShown(true); + } + } else { + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + } + } else { + setHasDateError(false); + } + }, + [filterEndDate, dateErrorShown] + ); + + const handleEndDateChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setFilterEndDate(value); + + if (value && filterStartDate) { + const startDateObj = new Date(filterStartDate); + const endDate = new Date(value); + + if (endDate < startDateObj) { + setHasDateError(true); + if (!dateErrorShown) { + toast.error('Tanggal akhir tidak boleh masa lampau', { + duration: Infinity, + }); + setDateErrorShown(true); + } + return; + } + } + + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }, + [filterStartDate, dateErrorShown] + ); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + // Date filter (start_date + end_date = 1 filter) + if (appliedFilterStartDate || appliedFilterEndDate) { + count += 1; + } + + // Area filter + if (appliedFilterArea.length > 0) { + count += 1; + } + + // Supplier filter + if (appliedFilterSupplier.length > 0) { + count += 1; + } + + // Product filter + if (appliedFilterProduct.length > 0) { + count += 1; + } + + // Product category filter + if (appliedFilterProductCategory.length > 0) { + count += 1; + } + + // Filter by type filter + if (appliedFilterByType) { + count += 1; + } + + // Sort by filter + if (appliedFilterSortBy) { + count += 1; + } + + return count; + }, [ + appliedFilterStartDate, + appliedFilterEndDate, + appliedFilterArea, + appliedFilterSupplier, + appliedFilterProduct, + appliedFilterProductCategory, + appliedFilterByType, + appliedFilterSortBy, + ]); + + const hasFilters = activeFiltersCount > 0; // ===== DATA FETCHING ===== const { data: purchasePerSupplier, isLoading } = useSWR( @@ -218,33 +320,38 @@ const PurchasesPerSupplierTab = () => { ? () => { const params = { area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') + appliedFilterArea.length > 0 + ? appliedFilterArea.map((v) => String(v.value)).join(',') : undefined, supplier_id: - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id.join(',') + appliedFilterSupplier.length > 0 + ? appliedFilterSupplier.map((v) => String(v.value)).join(',') : undefined, product_id: - tableFilterState.product_id.length > 0 - ? tableFilterState.product_id.join(',') + appliedFilterProduct.length > 0 + ? appliedFilterProduct.map((v) => String(v.value)).join(',') : undefined, product_category_id: - tableFilterState.product_category_id.length > 0 - ? tableFilterState.product_category_id.join(',') + appliedFilterProductCategory.length > 0 + ? appliedFilterProductCategory + .map((v) => String(v.value)) + .join(',') : undefined, received_date: - tableFilterState.filter_by === 'received_date' - ? tableFilterState.start_date || undefined + appliedFilterByType?.value === 'received_date' + ? appliedFilterStartDate || undefined : undefined, po_date: - tableFilterState.filter_by === 'po_date' - ? tableFilterState.start_date || undefined + appliedFilterByType?.value === 'po_date' + ? appliedFilterStartDate || undefined : undefined, - start_date: tableFilterState.start_date || undefined, - end_date: tableFilterState.end_date || undefined, - sort_by: tableFilterState.sort_by || undefined, - filter_by: tableFilterState.filter_by || undefined, + start_date: appliedFilterStartDate || undefined, + end_date: appliedFilterEndDate || undefined, + sort_by: + (appliedFilterSortBy?.value as 'ASC' | 'DESC') || undefined, + filter_by: + (appliedFilterByType?.value as 'received_date' | 'po_date') || + undefined, page: currentPage, limit: pageSize, }; @@ -289,33 +396,35 @@ const PurchasesPerSupplierTab = () => { > => { const params = { area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') + appliedFilterArea.length > 0 + ? appliedFilterArea.map((v) => String(v.value)).join(',') : undefined, supplier_id: - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id.join(',') + appliedFilterSupplier.length > 0 + ? appliedFilterSupplier.map((v) => String(v.value)).join(',') : undefined, product_id: - tableFilterState.product_id.length > 0 - ? tableFilterState.product_id.join(',') + appliedFilterProduct.length > 0 + ? appliedFilterProduct.map((v) => String(v.value)).join(',') : undefined, product_category_id: - tableFilterState.product_category_id.length > 0 - ? tableFilterState.product_category_id.join(',') + appliedFilterProductCategory.length > 0 + ? appliedFilterProductCategory.map((v) => String(v.value)).join(',') : undefined, received_date: - tableFilterState.filter_by === 'received_date' - ? tableFilterState.start_date || undefined + appliedFilterByType?.value === 'received_date' + ? appliedFilterStartDate || undefined : undefined, po_date: - tableFilterState.filter_by === 'po_date' - ? tableFilterState.start_date || undefined + appliedFilterByType?.value === 'po_date' + ? appliedFilterStartDate || undefined : undefined, - start_date: tableFilterState.start_date || undefined, - end_date: tableFilterState.end_date || undefined, - sort_by: tableFilterState.sort_by || undefined, - filter_by: tableFilterState.filter_by || undefined, + start_date: appliedFilterStartDate || undefined, + end_date: appliedFilterEndDate || undefined, + sort_by: (appliedFilterSortBy?.value as 'ASC' | 'DESC') || undefined, + filter_by: + (appliedFilterByType?.value as 'received_date' | 'po_date') || + undefined, limit: 100, page: 1, }; @@ -338,7 +447,16 @@ const PurchasesPerSupplierTab = () => { return isResponseSuccess(response) ? (response.data as unknown as LogisticPurchasePerSupplierReport[]) : null; - }, [tableFilterState]); + }, [ + appliedFilterArea, + appliedFilterSupplier, + appliedFilterProduct, + appliedFilterProductCategory, + appliedFilterStartDate, + appliedFilterEndDate, + appliedFilterByType, + appliedFilterSortBy, + ]); // ===== EXPORT HANDLERS ===== const handleExportExcel = useCallback(async () => { @@ -379,48 +497,26 @@ const PurchasesPerSupplierTab = () => { } const areaName = - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id - .map( - (id) => - areaOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Area' + appliedFilterArea.length > 0 + ? appliedFilterArea.map((c) => c.label).join(', ') || 'Semua Area' : 'Semua Area'; const supplierName = - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id - .map( - (id) => - supplierOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Supplier' + appliedFilterSupplier.length > 0 + ? appliedFilterSupplier.map((c) => c.label).join(', ') || + 'Semua Supplier' : 'Semua Supplier'; const productName = - tableFilterState.product_id.length > 0 - ? tableFilterState.product_id - .map( - (id) => - productOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Produk' + appliedFilterProduct.length > 0 + ? appliedFilterProduct.map((c) => c.label).join(', ') || + 'Semua Produk' : 'Semua Produk'; const productCategoryName = - tableFilterState.product_category_id.length > 0 - ? tableFilterState.product_category_id - .map( - (id) => - productCategoryOptions.find((opt) => opt.value === Number(id)) - ?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Kategori Produk' + appliedFilterProductCategory.length > 0 + ? appliedFilterProductCategory.map((c) => c.label).join(', ') || + 'Semua Kategori Produk' : 'Semua Kategori Produk'; const exportParams = { @@ -428,9 +524,11 @@ const PurchasesPerSupplierTab = () => { supplier_name: supplierName, product_name: productName, product_category_name: productCategoryName, - filter_by: tableFilterState.filter_by || 'received_date', - start_date: tableFilterState.start_date || '', - end_date: tableFilterState.end_date || '', + filter_by: + (appliedFilterByType?.value as 'received_date' | 'po_date') || + undefined, + start_date: appliedFilterStartDate || undefined, + end_date: appliedFilterEndDate || undefined, }; await generatePurchasesPerSupplierPDF({ @@ -445,33 +543,101 @@ const PurchasesPerSupplierTab = () => { } }, [ logisticPurchasePerSupplierExport, - tableFilterState, - areaOptions, - supplierOptions, - productOptions, - productCategoryOptions, + appliedFilterArea, + appliedFilterSupplier, + appliedFilterProduct, + appliedFilterProductCategory, + appliedFilterStartDate, + appliedFilterEndDate, + appliedFilterByType, ]); - // ===== PAGINATION HANDLERS ===== - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useLogisticStockTabStore( + (state) => state.setTabActions + ); + const clearTabActions = useLogisticStockTabStore( + (state) => state.clearTabActions + ); - const handleRowChange = (pageSize: number) => { - setPageSize(pageSize); - }; + useEffect(() => { + setTabActions( + tabId, +
+ - const handleNextPage = () => { - if (meta && currentPage < meta.total_pages) { - setCurrentPage(currentPage + 1); - } - }; + + + Export +
+ +
+ + } + align='end' + className={{ + content: + 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', + }} + > + + + + +
+
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + filterModal.open, + setTabActions, + ]); - const handlePrevPage = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; + useEffect(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); const getTableColumns = ( summary: LogisticPurchasePerSupplierSummary @@ -485,11 +651,11 @@ const PurchasesPerSupplierTab = () => { cell: (props) => props.row.index + 1, footer: () =>
Total
, }, - { id: 'received_date', header: 'Tanggal Terima', accessorKey: 'receive_date', + enableSorting: false, cell: (props) => { const value = props.row.original.receive_date; return formatDate(value, 'DD MMM YYYY'); @@ -499,6 +665,7 @@ const PurchasesPerSupplierTab = () => { id: 'po_date', header: 'Tanggal PO', accessorKey: 'po_date', + enableSorting: false, cell: (props) => { const value = props.row.original.po_date; return formatDate(value, 'DD MMM YYYY'); @@ -508,6 +675,7 @@ const PurchasesPerSupplierTab = () => { id: 'po_number', header: 'No. Referensi', accessorKey: 'po_number', + enableSorting: false, cell: (props) => { const value = props.row.original.po_number; return value || '-'; @@ -517,6 +685,7 @@ const PurchasesPerSupplierTab = () => { id: 'product_name', header: 'Nama Produk', accessorKey: 'product.name', + enableSorting: false, cell: (props) => { const product = props.row.original.product; return product?.name || '-'; @@ -526,6 +695,7 @@ const PurchasesPerSupplierTab = () => { id: 'destination_warehouse', header: 'Tujuan', accessorKey: 'warehouse.name', + enableSorting: false, cell: (props) => { const warehouse = props.row.original.warehouse; return warehouse?.name || '-'; @@ -535,6 +705,7 @@ const PurchasesPerSupplierTab = () => { id: 'qty', header: 'QTY', accessorKey: 'qty', + enableSorting: false, cell: (props) => { const value = props.row.original.qty; return
{formatNumber(value)}
; @@ -549,6 +720,7 @@ const PurchasesPerSupplierTab = () => { id: 'price', header: 'Harga Beli (Rp)', accessorKey: 'unit_price', + enableSorting: false, cell: (props) => { const value = props.row.original.unit_price; return
{formatCurrency(value)}
; @@ -563,6 +735,7 @@ const PurchasesPerSupplierTab = () => { id: 'purchase_amount', header: 'Value Harga Beli (Rp)', accessorKey: 'purchase_value', + enableSorting: false, cell: (props) => { const value = props.row.original.purchase_value; return
{formatCurrency(value)}
; @@ -577,6 +750,7 @@ const PurchasesPerSupplierTab = () => { id: 'transport', header: 'Transport (Rp)', accessorKey: 'transport_unit_price', + enableSorting: false, cell: (props) => { const value = props.row.original.transport_unit_price; return
{formatCurrency(value)}
; @@ -591,6 +765,7 @@ const PurchasesPerSupplierTab = () => { id: 'value_transport', header: 'Value Transport (Rp)', accessorKey: 'transport_value', + enableSorting: false, cell: (props) => { const value = props.row.original.transport_value; return
{formatCurrency(value)}
; @@ -605,6 +780,7 @@ const PurchasesPerSupplierTab = () => { id: 'total', header: 'Jumlah (Rp)', accessorKey: 'total_amount', + enableSorting: false, cell: (props) => { const value = props.row.original.total_amount; return
{formatCurrency(value)}
; @@ -619,6 +795,7 @@ const PurchasesPerSupplierTab = () => { id: 'expedition_vendor_name', header: 'Ekspedisi', accessorKey: 'expedition', + enableSorting: false, cell: (props) => { const value = props.row.original.expedition; return value || '-'; @@ -628,6 +805,7 @@ const PurchasesPerSupplierTab = () => { id: 'travel_number', header: 'Surat Jalan', accessorKey: 'delivery_number', + enableSorting: false, cell: (props) => { const value = props.row.original.delivery_number; return value || '-'; @@ -638,156 +816,50 @@ const PurchasesPerSupplierTab = () => { }; return ( -
- -
- - (tableFilterState.area_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={areaChangeHandler} - isLoading={isLoadingAreas} - isClearable - /> - - (tableFilterState.supplier_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={supplierChangeHandler} - isLoading={isLoadingSuppliers} - isClearable - /> - - (tableFilterState.product_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={productChangeHandler} - isLoading={isLoadingProducts} - isClearable - /> -
-
- - (tableFilterState.product_category_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={productCategoryChangeHandler} - isLoading={isLoadingProductCategories} - isClearable - /> -
- option.value === tableFilterState.filter_by - ) || null - } - onChange={dataTypeChangeHandler} - isLoading={false} - isClearable={false} - /> - option.value === tableFilterState.sort_by - ) || null - } - onChange={sortByHandler} - isLoading={false} - isClearable={false} - /> -
-
- - -
-
-
- - - - Export - - - } - align='end' - > - - - - - -
- + <> +
{!isSubmitted ? ( -
- Silakan pilih filter dan klik tombol Submit untuk menampilkan data. -
+ + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> ) : isLoading ? ( -
- -
+ + } + title='Memuat Data Pembelian Per Supplier' + subtitle='Silakan tunggu sebentar...' + /> ) : data.length === 0 ? ( -
- Tidak ada data yang dapat ditampilkan... -
+ + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> ) : ( data.map((supplierReport) => { const summary = supplierReport.summary || { @@ -808,15 +880,17 @@ const PurchasesPerSupplierTab = () => { title={supplierReport.supplier.name} subtitle={`Total Pembelian: ${formatCurrency(totalPurchase)}`} className={{ - wrapper: 'w-full rounded-2xl', + wrapper: 'w-full rounded-lg border-none', body: 'p-0', title: - 'py-1.5 px-3 bg-primary text-white text-lg font-normal', + 'px-2 py-1.5 font-normal text-sm bg-primary text-white', subtitle: - 'px-3 pb-1 bg-primary text-white text-sm font-normal', + 'px-2 pb-1.5 bg-primary text-white text-xs font-normal', + collapsible: 'rounded-lg', }} variant='bordered' collapsible={true} + defaultCollapsed={true} >
{ renderFooter={supplierReport.rows.length > 0} className={{ containerClassName: 'w-full mb-0!', - tableWrapperClassName: 'overflow-x-auto', + tableWrapperClassName: + 'overflow-x-auto rounded-tr-none rounded-tl-none', tableClassName: 'w-full table-auto text-sm', headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', headerColumnClassName: @@ -846,22 +921,161 @@ const PurchasesPerSupplierTab = () => { ); }) )} - - {meta && data.length > 0 && ( -
- + + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+
+ {/* Date Filter */} +
+ +
+ +
+ +
+
+ + {/* Area Filter */} + { + setFilterArea(Array.isArray(val) ? val : val ? [val] : []); + }} + isLoading={isLoadingAreas} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Supplier Filter */} + { + setFilterSupplier(Array.isArray(val) ? val : val ? [val] : []); + }} + isLoading={isLoadingSuppliers} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Filter */} + { + setFilterProduct(Array.isArray(val) ? val : val ? [val] : []); + }} + isLoading={isLoadingProducts} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Category Filter */} + { + setFilterProductCategory( + Array.isArray(val) ? val : val ? [val] : [] + ); + }} + isLoading={isLoadingProductCategories} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Filter By Type */} + { + if (val && !Array.isArray(val)) { + setFilterByType(val); + } + }} + className={{ wrapper: 'w-full' }} + /> + + {/* Sort By */} + { + if (val && !Array.isArray(val)) { + setFilterSortBy(val); + } + }} + className={{ wrapper: 'w-full' }} />
- )} -
+ + {/* Modal Footer */} +
+ + +
+ + ); }; diff --git a/src/stores/logistic-stock-tab/logistic-stock-tab.store.ts b/src/stores/logistic-stock-tab/logistic-stock-tab.store.ts new file mode 100644 index 00000000..f9e142b1 --- /dev/null +++ b/src/stores/logistic-stock-tab/logistic-stock-tab.store.ts @@ -0,0 +1,51 @@ +'use client'; + +import { ReactNode } from 'react'; +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export type LogisticStockTabActionsSlice = { + // State - actions per tab ID + tabActions: Record; + + // Actions + setTabActions: (tabId: string, actions: ReactNode) => void; + clearTabActions: (tabId: string) => void; + clearAllTabActions: () => void; +}; + +export const useLogisticStockTabStore = create()( + devtools( + (set) => ({ + tabActions: {}, + + setTabActions: (tabId, actions) => + set( + (state) => ({ + tabActions: { + ...state.tabActions, + [tabId]: actions, + }, + }), + false, + 'setTabActions' + ), + + clearTabActions: (tabId) => + set( + (state) => { + const { [tabId]: _, ...rest } = state.tabActions; + return { tabActions: rest }; + }, + false, + 'clearTabActions' + ), + + clearAllTabActions: () => + set({ tabActions: {} }, false, 'clearAllTabActions'), + }), + { + name: 'LogisticStockTabStore', + } + ) +); From ed781da372b1a89c4f07240eff5d3cdd02cf57a2 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 11 Feb 2026 16:12:14 +0700 Subject: [PATCH 02/36] refactor(FE): Change Tabs variant from 'lifted' to 'boxed' --- .../pages/report/logistic-stock/LogisticStockTabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx index a7b844f3..f06e63dc 100644 --- a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx +++ b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx @@ -31,7 +31,7 @@ const LogisticStockTabs = () => {
Date: Wed, 11 Feb 2026 16:17:15 +0700 Subject: [PATCH 03/36] refactor(FE): Make dropdown filters clearable in PurchasesPerSupplierTab --- .../report/logistic-stock/tab/PurchasesPerSupplierTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 4176e8ba..023df222 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -1035,11 +1035,12 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { options={dataTypeOptions} value={filterByType} onChange={(val) => { - if (val && !Array.isArray(val)) { + if (!Array.isArray(val)) { setFilterByType(val); } }} className={{ wrapper: 'w-full' }} + isClearable={true} /> {/* Sort By */} @@ -1049,11 +1050,12 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { options={sortByOptions} value={filterSortBy} onChange={(val) => { - if (val && !Array.isArray(val)) { + if (!Array.isArray(val)) { setFilterSortBy(val); } }} className={{ wrapper: 'w-full' }} + isClearable={true} /> From 52d58d0921ff351c3a6d69f3fa444f053c9ee21c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 11 Feb 2026 16:44:10 +0700 Subject: [PATCH 04/36] refactor(FE): Refactor PurchasesPerSupplierTab to use Formik for filters --- .../filter/PurchasesPerSupplierFilter.ts | 89 +++ .../tab/PurchasesPerSupplierTab.tsx | 755 ++++++++---------- 2 files changed, 439 insertions(+), 405 deletions(-) create mode 100644 src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts diff --git a/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts new file mode 100644 index 00000000..70a01615 --- /dev/null +++ b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts @@ -0,0 +1,89 @@ +import { OptionType } from '@/components/input/SelectInput'; +import * as yup from 'yup'; + +export type PurchasesPerSupplierFilterType = { + start_date: string | null | undefined; + end_date: string | null | undefined; + area_ids: OptionType[] | null | undefined; + supplier_ids: OptionType[] | null | undefined; + product_ids: OptionType[] | null | undefined; + product_category_ids: OptionType[] | null | undefined; + filter_by: OptionType | null | undefined; + sort_by: OptionType | null | undefined; +}; + +export const PurchasesPerSupplierFilterSchema: yup.ObjectSchema = + yup.object({ + start_date: yup.string().optional().nullable(), + end_date: yup + .string() + .optional() + .nullable() + .test( + 'is-greater-than-start', + 'Tanggal akhir tidak boleh masa lampau', + function (value) { + const { start_date } = this.parent; + if (!start_date || !value) return true; + return new Date(value) >= new Date(start_date); + } + ), + area_ids: yup + .array() + .of( + yup.object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + ) + .optional() + .nullable(), + supplier_ids: yup + .array() + .of( + yup.object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + ) + .optional() + .nullable(), + product_ids: yup + .array() + .of( + yup.object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + ) + .optional() + .nullable(), + product_category_ids: yup + .array() + .of( + yup.object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + ) + .optional() + .nullable(), + filter_by: yup + .object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + .optional() + .nullable(), + sort_by: yup + .object({ + value: yup.mixed().required(), + label: yup.string().required(), + }) + .optional() + .nullable(), + }); + +export type PurchasesPerSupplierFilterValues = yup.InferType< + typeof PurchasesPerSupplierFilterSchema +>; diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 023df222..4a070b56 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -1,40 +1,55 @@ -import { useState, useMemo, useCallback, useEffect } from 'react'; -import useSWR from 'swr'; +import Button from '@/components/Button'; import Card from '@/components/Card'; -import { useSelect, OptionType } from '@/components/input/SelectInput'; -import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; -import SelectInputRadio from '@/components/input/SelectInputRadio'; +import Dropdown from '@/components/Dropdown'; import DateInput from '@/components/input/DateInput'; +import { OptionType, useSelect } from '@/components/input/SelectInput'; +import Menu from '@/components/menu/Menu'; +import MenuItem from '@/components/menu/MenuItem'; +import Modal, { useModal } from '@/components/Modal'; +import Table from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { AreaApi } from '@/services/api/master-data'; import { SupplierApi } from '@/services/api/master-data'; import { ProductApi } from '@/services/api/master-data'; import { ProductCategoryApi } from '@/services/api/master-data'; import { LogisticApi } from '@/services/api/report/logistic-stock'; -import Table from '@/components/Table'; -import { ColumnDef } from '@tanstack/react-table'; -import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper'; import { LogisticPurchasePerSupplierReport, LogisticPurchasePerSupplierSummary, } from '@/types/api/report/logistic-stock'; -import { isResponseSuccess } from '@/lib/api-helper'; -import Button from '@/components/Button'; -import Dropdown from '@/components/Dropdown'; -import MenuItem from '@/components/menu/MenuItem'; -import Menu from '@/components/menu/Menu'; -import Modal from '@/components/Modal'; -import { useModal } from '@/components/Modal'; -import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF'; import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX'; -import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; -import toast from 'react-hot-toast'; +import { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF'; import { Icon } from '@iconify/react'; +import { ColumnDef } from '@tanstack/react-table'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import toast from 'react-hot-toast'; +import useSWR from 'swr'; +import { useFormik } from 'formik'; +import { + PurchasesPerSupplierFilterSchema, + PurchasesPerSupplierFilterType, +} from '@/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter'; +import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; +import SelectInputRadio from '@/components/input/SelectInputRadio'; import { useLogisticStockTabStore } from '@/stores/logistic-stock-tab/logistic-stock-tab.store'; +import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; interface PurchasesPerSupplierTabProps { tabId: string; } +interface FilterParams { + area_ids?: string; + supplier_ids?: string; + product_ids?: string; + product_category_ids?: string; + start_date?: string; + end_date?: string; + sort_by?: string; + filter_by?: string; +} + const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); @@ -46,11 +61,14 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { const [pageSize] = useState(10); // ===== SUBMISSION STATE ===== + const [filterParams, setFilterParams] = useState({}); const [isSubmitted, setIsSubmitted] = useState(false); + const [dateErrorShown, setDateErrorShown] = useState(false); + const [hasDateError, setHasDateError] = useState(false); const filterModal = useModal(); - // ===== OPTIONS (Declare before filter state) ===== + // ===== OPTIONS ===== const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( AreaApi.basePath, 'id', @@ -87,127 +105,66 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { [] ); - // ===== APPLIED FILTER STATE (Yang sudah di-apply) ===== - const [appliedFilterArea, setAppliedFilterArea] = useState< - typeof areaOptions - >([]); - const [appliedFilterSupplier, setAppliedFilterSupplier] = useState< - typeof supplierOptions - >([]); - const [appliedFilterProduct, setAppliedFilterProduct] = useState< - typeof productOptions - >([]); - const [appliedFilterProductCategory, setAppliedFilterProductCategory] = - useState([]); - const [appliedFilterByType, setAppliedFilterByType] = useState< - (typeof dataTypeOptions)[0] | null - >(null); - const [appliedFilterSortBy, setAppliedFilterSortBy] = useState< - (typeof sortByOptions)[0] | null - >(null); - const [appliedFilterStartDate, setAppliedFilterStartDate] = useState(''); - const [appliedFilterEndDate, setAppliedFilterEndDate] = useState(''); - const [dateErrorShown, setDateErrorShown] = useState(false); - const [hasDateError, setHasDateError] = useState(false); - - // ===== PENDING FILTER STATE (Yang ada di modal, belum di-apply) ===== - const [filterArea, setFilterArea] = useState([]); - const [filterSupplier, setFilterSupplier] = useState( - [] - ); - const [filterProduct, setFilterProduct] = useState([]); - const [filterProductCategory, setFilterProductCategory] = useState< - typeof productCategoryOptions - >([]); - const [filterByType, setFilterByType] = useState< - (typeof dataTypeOptions)[0] | null - >(null); - const [filterSortBy, setFilterSortBy] = useState< - (typeof sortByOptions)[0] | null - >(null); - const [filterStartDate, setFilterStartDate] = useState(''); - const [filterEndDate, setFilterEndDate] = useState(''); - - // ===== FILTER HANDLERS ===== - const handleFilterModalOpen = useCallback(() => { - setFilterArea(appliedFilterArea); - setFilterSupplier(appliedFilterSupplier); - setFilterProduct(appliedFilterProduct); - setFilterProductCategory(appliedFilterProductCategory); - setFilterByType(appliedFilterByType); - setFilterSortBy(appliedFilterSortBy); - setFilterStartDate(appliedFilterStartDate); - setFilterEndDate(appliedFilterEndDate); + const handleFilterModalOpen = () => { filterModal.openModal(); - }, [ - filterModal, - appliedFilterArea, - appliedFilterSupplier, - appliedFilterProduct, - appliedFilterProductCategory, - appliedFilterByType, - appliedFilterSortBy, - appliedFilterStartDate, - appliedFilterEndDate, - ]); + }; - const handleResetFilters = useCallback(() => { - setIsSubmitted(false); - setFilterArea([]); - setFilterSupplier([]); - setFilterProduct([]); - setFilterProductCategory([]); - setFilterByType(null); - setFilterSortBy(null); - setFilterStartDate(''); - setFilterEndDate(''); - setAppliedFilterArea([]); - setAppliedFilterSupplier([]); - setAppliedFilterProduct([]); - setAppliedFilterProductCategory([]); - setAppliedFilterByType(null); - setAppliedFilterSortBy(null); - setAppliedFilterStartDate(''); - setAppliedFilterEndDate(''); - setHasDateError(false); - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(false); - } - }, [dateErrorShown]); - - const handleApplyFilters = useCallback(() => { - setAppliedFilterArea(filterArea); - setAppliedFilterSupplier(filterSupplier); - setAppliedFilterProduct(filterProduct); - setAppliedFilterProductCategory(filterProductCategory); - setAppliedFilterByType(filterByType); - setAppliedFilterSortBy(filterSortBy); - setAppliedFilterStartDate(filterStartDate); - setAppliedFilterEndDate(filterEndDate); - setIsSubmitted(true); - setCurrentPage(1); - filterModal.closeModal(); - }, [ - filterModal, - filterArea, - filterSupplier, - filterProduct, - filterProductCategory, - filterByType, - filterSortBy, - filterStartDate, - filterEndDate, - ]); + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + start_date: null, + end_date: null, + area_ids: null, + supplier_ids: null, + product_ids: null, + product_category_ids: null, + filter_by: null, + sort_by: null, + }, + validationSchema: PurchasesPerSupplierFilterSchema, + onSubmit: (values) => { + setFilterParams({ + start_date: values.start_date?.toString() || undefined, + end_date: values.end_date?.toString() || undefined, + area_ids: + values.area_ids?.map((v) => String(v.value)).join(',') || undefined, + supplier_ids: + values.supplier_ids?.map((v) => String(v.value)).join(',') || + undefined, + product_ids: + values.product_ids?.map((v) => String(v.value)).join(',') || + undefined, + product_category_ids: + values.product_category_ids?.map((v) => String(v.value)).join(',') || + undefined, + filter_by: values.filter_by?.value?.toString() || undefined, + sort_by: values.sort_by?.value?.toString() || undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setCurrentPage(1); + }, + onReset: () => { + setFilterParams({}); + setIsSubmitted(false); + setCurrentPage(1); + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }, + }); + // ===== DATE CHANGE HANDLERS ===== const handleStartDateChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; - setFilterStartDate(value); + formik.setFieldValue('start_date', value || null); - if (value && filterEndDate) { + if (value && formik.values.end_date) { const startDate = new Date(value); - const endDateObj = new Date(filterEndDate); + const endDateObj = new Date(formik.values.end_date); if (endDateObj < startDate) { setHasDateError(true); @@ -228,16 +185,16 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { setHasDateError(false); } }, - [filterEndDate, dateErrorShown] + [formik, dateErrorShown] ); const handleEndDateChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; - setFilterEndDate(value); + formik.setFieldValue('end_date', value || null); - if (value && filterStartDate) { - const startDateObj = new Date(filterStartDate); + if (value && formik.values.start_date) { + const startDateObj = new Date(formik.values.start_date); const endDate = new Date(value); if (endDate < startDateObj) { @@ -258,59 +215,43 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { setDateErrorShown(false); } }, - [filterStartDate, dateErrorShown] + [formik, dateErrorShown] ); // ===== ACTIVE FILTERS COUNT ===== const activeFiltersCount = useMemo(() => { let count = 0; - // Date filter (start_date + end_date = 1 filter) - if (appliedFilterStartDate || appliedFilterEndDate) { + if (filterParams.start_date || filterParams.end_date) { count += 1; } - // Area filter - if (appliedFilterArea.length > 0) { + if (filterParams.area_ids) { count += 1; } - // Supplier filter - if (appliedFilterSupplier.length > 0) { + if (filterParams.supplier_ids) { count += 1; } - // Product filter - if (appliedFilterProduct.length > 0) { + if (filterParams.product_ids) { count += 1; } - // Product category filter - if (appliedFilterProductCategory.length > 0) { + if (filterParams.product_category_ids) { count += 1; } - // Filter by type filter - if (appliedFilterByType) { + if (filterParams.filter_by) { count += 1; } - // Sort by filter - if (appliedFilterSortBy) { + if (filterParams.sort_by) { count += 1; } return count; - }, [ - appliedFilterStartDate, - appliedFilterEndDate, - appliedFilterArea, - appliedFilterSupplier, - appliedFilterProduct, - appliedFilterProductCategory, - appliedFilterByType, - appliedFilterSortBy, - ]); + }, [filterParams]); const hasFilters = activeFiltersCount > 0; @@ -319,39 +260,14 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { isSubmitted ? () => { const params = { - area_id: - appliedFilterArea.length > 0 - ? appliedFilterArea.map((v) => String(v.value)).join(',') - : undefined, - supplier_id: - appliedFilterSupplier.length > 0 - ? appliedFilterSupplier.map((v) => String(v.value)).join(',') - : undefined, - product_id: - appliedFilterProduct.length > 0 - ? appliedFilterProduct.map((v) => String(v.value)).join(',') - : undefined, - product_category_id: - appliedFilterProductCategory.length > 0 - ? appliedFilterProductCategory - .map((v) => String(v.value)) - .join(',') - : undefined, - received_date: - appliedFilterByType?.value === 'received_date' - ? appliedFilterStartDate || undefined - : undefined, - po_date: - appliedFilterByType?.value === 'po_date' - ? appliedFilterStartDate || undefined - : undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, - sort_by: - (appliedFilterSortBy?.value as 'ASC' | 'DESC') || undefined, - filter_by: - (appliedFilterByType?.value as 'received_date' | 'po_date') || - undefined, + area_ids: filterParams.area_ids, + supplier_ids: filterParams.supplier_ids, + product_ids: filterParams.product_ids, + product_category_ids: filterParams.product_category_ids, + start_date: filterParams.start_date, + end_date: filterParams.end_date, + sort_by: filterParams.sort_by, + filter_by: filterParams.filter_by, page: currentPage, limit: pageSize, }; @@ -361,12 +277,12 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { : null, ([, params]) => LogisticApi.getLogisticPurchasePerSupplierReport( - params.area_id, - params.supplier_id, - params.product_id, - params.product_category_id, - params.received_date, - params.po_date, + params.area_ids, + params.supplier_ids, + params.product_ids, + params.product_category_ids, + params.filter_by === 'received_date' ? params.start_date : undefined, + params.filter_by === 'po_date' ? params.start_date : undefined, params.start_date, params.end_date, params.sort_by, @@ -395,47 +311,25 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { LogisticPurchasePerSupplierReport[] | null > => { const params = { - area_id: - appliedFilterArea.length > 0 - ? appliedFilterArea.map((v) => String(v.value)).join(',') - : undefined, - supplier_id: - appliedFilterSupplier.length > 0 - ? appliedFilterSupplier.map((v) => String(v.value)).join(',') - : undefined, - product_id: - appliedFilterProduct.length > 0 - ? appliedFilterProduct.map((v) => String(v.value)).join(',') - : undefined, - product_category_id: - appliedFilterProductCategory.length > 0 - ? appliedFilterProductCategory.map((v) => String(v.value)).join(',') - : undefined, - received_date: - appliedFilterByType?.value === 'received_date' - ? appliedFilterStartDate || undefined - : undefined, - po_date: - appliedFilterByType?.value === 'po_date' - ? appliedFilterStartDate || undefined - : undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, - sort_by: (appliedFilterSortBy?.value as 'ASC' | 'DESC') || undefined, - filter_by: - (appliedFilterByType?.value as 'received_date' | 'po_date') || - undefined, + area_ids: filterParams.area_ids, + supplier_ids: filterParams.supplier_ids, + product_ids: filterParams.product_ids, + product_category_ids: filterParams.product_category_ids, + start_date: filterParams.start_date, + end_date: filterParams.end_date, + sort_by: filterParams.sort_by, + filter_by: filterParams.filter_by, limit: 100, page: 1, }; const response = await LogisticApi.getLogisticPurchasePerSupplierReport( - params.area_id, - params.supplier_id, - params.product_id, - params.product_category_id, - params.received_date, - params.po_date, + params.area_ids, + params.supplier_ids, + params.product_ids, + params.product_category_ids, + params.filter_by === 'received_date' ? params.start_date : undefined, + params.filter_by === 'po_date' ? params.start_date : undefined, params.start_date, params.end_date, params.sort_by, @@ -447,16 +341,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { return isResponseSuccess(response) ? (response.data as unknown as LogisticPurchasePerSupplierReport[]) : null; - }, [ - appliedFilterArea, - appliedFilterSupplier, - appliedFilterProduct, - appliedFilterProductCategory, - appliedFilterStartDate, - appliedFilterEndDate, - appliedFilterByType, - appliedFilterSortBy, - ]); + }, [filterParams]); // ===== EXPORT HANDLERS ===== const handleExportExcel = useCallback(async () => { @@ -496,39 +381,52 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { return; } - const areaName = - appliedFilterArea.length > 0 - ? appliedFilterArea.map((c) => c.label).join(', ') || 'Semua Area' - : 'Semua Area'; + const areaName = filterParams.area_ids + ? areaOptions + .filter((opt) => + filterParams.area_ids?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Area' + : 'Semua Area'; - const supplierName = - appliedFilterSupplier.length > 0 - ? appliedFilterSupplier.map((c) => c.label).join(', ') || - 'Semua Supplier' - : 'Semua Supplier'; + const supplierName = filterParams.supplier_ids + ? supplierOptions + .filter((opt) => + filterParams.supplier_ids?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Supplier' + : 'Semua Supplier'; - const productName = - appliedFilterProduct.length > 0 - ? appliedFilterProduct.map((c) => c.label).join(', ') || - 'Semua Produk' - : 'Semua Produk'; + const productName = filterParams.product_ids + ? productOptions + .filter((opt) => + filterParams.product_ids?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Produk' + : 'Semua Produk'; - const productCategoryName = - appliedFilterProductCategory.length > 0 - ? appliedFilterProductCategory.map((c) => c.label).join(', ') || - 'Semua Kategori Produk' - : 'Semua Kategori Produk'; + const productCategoryName = filterParams.product_category_ids + ? productCategoryOptions + .filter((opt) => + filterParams.product_category_ids + ?.split(',') + .includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Kategori Produk' + : 'Semua Kategori Produk'; const exportParams = { area_name: areaName, supplier_name: supplierName, product_name: productName, product_category_name: productCategoryName, - filter_by: - (appliedFilterByType?.value as 'received_date' | 'po_date') || - undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, + filter_by: filterParams.filter_by, + start_date: filterParams.start_date, + end_date: filterParams.end_date, }; await generatePurchasesPerSupplierPDF({ @@ -543,13 +441,11 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { } }, [ logisticPurchasePerSupplierExport, - appliedFilterArea, - appliedFilterSupplier, - appliedFilterProduct, - appliedFilterProductCategory, - appliedFilterStartDate, - appliedFilterEndDate, - appliedFilterByType, + filterParams, + areaOptions, + supplierOptions, + productOptions, + productCategoryOptions, ]); // ===== REGISTER TAB ACTIONS TO STORE ===== @@ -624,6 +520,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ tabId, hasFilters, @@ -633,6 +530,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { setTabActions, ]); + // Cleanup on unmount useEffect(() => { return () => { clearTabActions(tabId); @@ -945,137 +843,184 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { -
- {/* Date Filter */} -
- -
- -
- +
+
+ {/* Date Filter */} +
+ +
+ +
+ +
+ + {/* Area Filter */} + { + formik.setFieldValue( + 'area_ids', + Array.isArray(val) ? val : val ? [val] : null + ); + }} + isLoading={isLoadingAreas} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Supplier Filter */} + { + formik.setFieldValue( + 'supplier_ids', + Array.isArray(val) ? val : val ? [val] : null + ); + }} + isLoading={isLoadingSuppliers} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Filter */} + { + formik.setFieldValue( + 'product_ids', + Array.isArray(val) ? val : val ? [val] : null + ); + }} + isLoading={isLoadingProducts} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Category Filter */} + { + formik.setFieldValue( + 'product_category_ids', + Array.isArray(val) ? val : val ? [val] : null + ); + }} + isLoading={isLoadingProductCategories} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Filter By Type */} + { + if (!Array.isArray(val)) { + formik.setFieldValue('filter_by', val); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + /> + + {/* Sort By */} + { + if (!Array.isArray(val)) { + formik.setFieldValue('sort_by', val); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + />
- {/* Area Filter */} - { - setFilterArea(Array.isArray(val) ? val : val ? [val] : []); - }} - isLoading={isLoadingAreas} - isClearable - className={{ wrapper: 'w-full' }} - /> - - {/* Supplier Filter */} - { - setFilterSupplier(Array.isArray(val) ? val : val ? [val] : []); - }} - isLoading={isLoadingSuppliers} - isClearable - className={{ wrapper: 'w-full' }} - /> - - {/* Product Filter */} - { - setFilterProduct(Array.isArray(val) ? val : val ? [val] : []); - }} - isLoading={isLoadingProducts} - isClearable - className={{ wrapper: 'w-full' }} - /> - - {/* Product Category Filter */} - { - setFilterProductCategory( - Array.isArray(val) ? val : val ? [val] : [] - ); - }} - isLoading={isLoadingProductCategories} - isClearable - className={{ wrapper: 'w-full' }} - /> - - {/* Filter By Type */} - { - if (!Array.isArray(val)) { - setFilterByType(val); - } - }} - className={{ wrapper: 'w-full' }} - isClearable={true} - /> - - {/* Sort By */} - { - if (!Array.isArray(val)) { - setFilterSortBy(val); - } - }} - className={{ wrapper: 'w-full' }} - isClearable={true} - /> -
- - {/* Modal Footer */} -
- - -
+ {/* Modal Footer */} +
+ + +
+ ); From 166e95930b8cb892730ed2108b2b758e94efe37d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 09:35:24 +0700 Subject: [PATCH 05/36] refactor(FE): Remove unused imports and cleanup comments --- .../report/logistic-stock/tab/PurchasesPerSupplierTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 4a070b56..9702d904 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -2,7 +2,7 @@ import Button from '@/components/Button'; import Card from '@/components/Card'; import Dropdown from '@/components/Dropdown'; import DateInput from '@/components/input/DateInput'; -import { OptionType, useSelect } from '@/components/input/SelectInput'; +import { useSelect } from '@/components/input/SelectInput'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import Modal, { useModal } from '@/components/Modal'; @@ -520,7 +520,6 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => {
); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ tabId, hasFilters, @@ -530,7 +529,6 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { setTabActions, ]); - // Cleanup on unmount useEffect(() => { return () => { clearTabActions(tabId); From 62dd1de150c7745e9f13fd7cc31f9240189a77f3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 09:45:18 +0700 Subject: [PATCH 06/36] refactor(FE): Reset form values on filter modal open and submit --- .../report/logistic-stock/tab/PurchasesPerSupplierTab.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 9702d904..09a96b23 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -107,6 +107,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { const handleFilterModalOpen = () => { filterModal.openModal(); + formik.resetForm({ values: formik.values }); }; // ===== FORMIK SETUP ===== @@ -122,7 +123,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { sort_by: null, }, validationSchema: PurchasesPerSupplierFilterSchema, - onSubmit: (values) => { + onSubmit: (values, { resetForm }) => { setFilterParams({ start_date: values.start_date?.toString() || undefined, end_date: values.end_date?.toString() || undefined, @@ -143,6 +144,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { filterModal.closeModal(); setIsSubmitted(true); setCurrentPage(1); + resetForm({ values }); }, onReset: () => { setFilterParams({}); From 28dabcbeb6f40d61e2f9b1c336bdd0bf6f6937a9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 09:52:21 +0700 Subject: [PATCH 07/36] refactor(FE): Refactor filter parameter keys to singular form --- .../tab/PurchasesPerSupplierTab.tsx | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 09a96b23..794c45d6 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -40,10 +40,10 @@ interface PurchasesPerSupplierTabProps { } interface FilterParams { - area_ids?: string; - supplier_ids?: string; - product_ids?: string; - product_category_ids?: string; + area_id?: string; + supplier_id?: string; + product_id?: string; + product_category_id?: string; start_date?: string; end_date?: string; sort_by?: string; @@ -107,7 +107,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { const handleFilterModalOpen = () => { filterModal.openModal(); - formik.resetForm({ values: formik.values }); + formik.validateForm(); }; // ===== FORMIK SETUP ===== @@ -123,19 +123,19 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { sort_by: null, }, validationSchema: PurchasesPerSupplierFilterSchema, - onSubmit: (values, { resetForm }) => { + onSubmit: (values, { setSubmitting }) => { setFilterParams({ start_date: values.start_date?.toString() || undefined, end_date: values.end_date?.toString() || undefined, - area_ids: + area_id: values.area_ids?.map((v) => String(v.value)).join(',') || undefined, - supplier_ids: + supplier_id: values.supplier_ids?.map((v) => String(v.value)).join(',') || undefined, - product_ids: + product_id: values.product_ids?.map((v) => String(v.value)).join(',') || undefined, - product_category_ids: + product_category_id: values.product_category_ids?.map((v) => String(v.value)).join(',') || undefined, filter_by: values.filter_by?.value?.toString() || undefined, @@ -144,7 +144,7 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { filterModal.closeModal(); setIsSubmitted(true); setCurrentPage(1); - resetForm({ values }); + setSubmitting(false); }, onReset: () => { setFilterParams({}); @@ -228,19 +228,19 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { count += 1; } - if (filterParams.area_ids) { + if (filterParams.area_id) { count += 1; } - if (filterParams.supplier_ids) { + if (filterParams.supplier_id) { count += 1; } - if (filterParams.product_ids) { + if (filterParams.product_id) { count += 1; } - if (filterParams.product_category_ids) { + if (filterParams.product_category_id) { count += 1; } @@ -262,10 +262,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { isSubmitted ? () => { const params = { - area_ids: filterParams.area_ids, - supplier_ids: filterParams.supplier_ids, - product_ids: filterParams.product_ids, - product_category_ids: filterParams.product_category_ids, + area_id: filterParams.area_id, + supplier_id: filterParams.supplier_id, + product_id: filterParams.product_id, + product_category_id: filterParams.product_category_id, start_date: filterParams.start_date, end_date: filterParams.end_date, sort_by: filterParams.sort_by, @@ -279,10 +279,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { : null, ([, params]) => LogisticApi.getLogisticPurchasePerSupplierReport( - params.area_ids, - params.supplier_ids, - params.product_ids, - params.product_category_ids, + params.area_id, + params.supplier_id, + params.product_id, + params.product_category_id, params.filter_by === 'received_date' ? params.start_date : undefined, params.filter_by === 'po_date' ? params.start_date : undefined, params.start_date, @@ -313,10 +313,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { LogisticPurchasePerSupplierReport[] | null > => { const params = { - area_ids: filterParams.area_ids, - supplier_ids: filterParams.supplier_ids, - product_ids: filterParams.product_ids, - product_category_ids: filterParams.product_category_ids, + area_id: filterParams.area_id, + supplier_id: filterParams.supplier_id, + product_id: filterParams.product_id, + product_category_id: filterParams.product_category_id, start_date: filterParams.start_date, end_date: filterParams.end_date, sort_by: filterParams.sort_by, @@ -326,10 +326,10 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { }; const response = await LogisticApi.getLogisticPurchasePerSupplierReport( - params.area_ids, - params.supplier_ids, - params.product_ids, - params.product_category_ids, + params.area_id, + params.supplier_id, + params.product_id, + params.product_category_id, params.filter_by === 'received_date' ? params.start_date : undefined, params.filter_by === 'po_date' ? params.start_date : undefined, params.start_date, @@ -383,37 +383,37 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { return; } - const areaName = filterParams.area_ids + const areaName = filterParams.area_id ? areaOptions .filter((opt) => - filterParams.area_ids?.split(',').includes(String(opt.value)) + filterParams.area_id?.split(',').includes(String(opt.value)) ) .map((opt) => opt.label) .join(', ') || 'Semua Area' : 'Semua Area'; - const supplierName = filterParams.supplier_ids + const supplierName = filterParams.supplier_id ? supplierOptions .filter((opt) => - filterParams.supplier_ids?.split(',').includes(String(opt.value)) + filterParams.supplier_id?.split(',').includes(String(opt.value)) ) .map((opt) => opt.label) .join(', ') || 'Semua Supplier' : 'Semua Supplier'; - const productName = filterParams.product_ids + const productName = filterParams.product_id ? productOptions .filter((opt) => - filterParams.product_ids?.split(',').includes(String(opt.value)) + filterParams.product_id?.split(',').includes(String(opt.value)) ) .map((opt) => opt.label) .join(', ') || 'Semua Produk' : 'Semua Produk'; - const productCategoryName = filterParams.product_category_ids + const productCategoryName = filterParams.product_category_id ? productCategoryOptions .filter((opt) => - filterParams.product_category_ids + filterParams.product_category_id ?.split(',') .includes(String(opt.value)) ) From fd78ca6ac1aa410f0789e0453dd7f5f82f27e263 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 10:28:36 +0700 Subject: [PATCH 08/36] refactor(FE): Refactor CustomerPaymentTab to use Formik for filter management --- .../finance/filter/CustomerPaymentFilter.ts | 31 ++ .../report/finance/tab/CustomerPaymentTab.tsx | 419 ++++++++---------- 2 files changed, 211 insertions(+), 239 deletions(-) create mode 100644 src/components/pages/report/finance/filter/CustomerPaymentFilter.ts diff --git a/src/components/pages/report/finance/filter/CustomerPaymentFilter.ts b/src/components/pages/report/finance/filter/CustomerPaymentFilter.ts new file mode 100644 index 00000000..60359038 --- /dev/null +++ b/src/components/pages/report/finance/filter/CustomerPaymentFilter.ts @@ -0,0 +1,31 @@ +import * as yup from 'yup'; + +export type CustomerPaymentFilterType = { + start_date: string | null; + end_date: string | null; + customer_ids: string | null; + filter_by: string | null; +}; + +export const CustomerPaymentFilterSchema = yup.object({ + start_date: yup.string().optional().nullable(), + end_date: yup + .string() + .optional() + .nullable() + .test( + 'is-greater-than-start', + 'Tanggal akhir tidak boleh masa lampau', + function (value) { + const { start_date } = this.parent; + if (!start_date || !value) return true; + return new Date(value) >= new Date(start_date); + } + ), + customer_ids: yup.string().nullable(), + filter_by: yup.string().nullable(), +}); + +export type CustomerPaymentFilterValues = yup.InferType< + typeof CustomerPaymentFilterSchema +>; diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index 4e0e3f25..723a1ebf 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -9,7 +9,6 @@ import SelectInputRadio from '@/components/input/SelectInputRadio'; import DateInput from '@/components/input/DateInput'; import { CustomerApi } from '@/services/api/master-data'; import { FinanceApi } from '@/services/api/report/finance-report'; -// import { UserApi } from '@/services/api/user'; import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper'; @@ -22,18 +21,30 @@ import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; import MenuItem from '@/components/menu/MenuItem'; import Menu from '@/components/menu/Menu'; -import Modal from '@/components/Modal'; -import { useModal } from '@/components/Modal'; +import Modal, { useModal } from '@/components/Modal'; import toast from 'react-hot-toast'; +import { useFormik } from 'formik'; +import { + CustomerPaymentFilterSchema, + CustomerPaymentFilterType, +} from '@/components/pages/report/finance/filter/CustomerPaymentFilter'; import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; +import { OptionType } from '@/components/table/TableRowSizeSelector'; interface CustomerPaymentTabProps { tabId: string; } +interface FilterParams { + customer_ids?: string; + start_date?: string; + end_date?: string; + filter_by?: string; +} + const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); @@ -46,31 +57,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { // ===== SUBMISSION STATE ===== const [isSubmitted, setIsSubmitted] = useState(false); - - // ===== FILTER STATE ===== - const [appliedFilterCustomer, setAppliedFilterCustomer] = useState< - typeof customerOptions - >([]); - // TODO: Uncomment when BE is ready - // const [appliedFilterSales, setAppliedFilterSales] = useState< - // typeof salesOptions - // >([]); - const [appliedFilterByType, setAppliedFilterByType] = useState< - (typeof dataTypeOptions)[0] | null - >(null); - const [appliedFilterStartDate, setAppliedFilterStartDate] = useState(''); - const [appliedFilterEndDate, setAppliedFilterEndDate] = useState(''); + const [filterParams, setFilterParams] = useState({}); const [dateErrorShown, setDateErrorShown] = useState(false); const [hasDateError, setHasDateError] = useState(false); - const [filterCustomer, setFilterCustomer] = useState( - [] - ); - // TODO: Uncomment when BE is ready - // const [filterSales, setFilterSales] = useState([]); - const [filterStartDate, setFilterStartDate] = useState(''); - const [filterEndDate, setFilterEndDate] = useState(''); - const filterModal = useModal(); const dataTypeOptions = useMemo( @@ -81,10 +71,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { [] ); - const [filterByType, setFilterByType] = useState< - (typeof dataTypeOptions)[0] | null - >(null); - const { options: customerOptions, setInputValue: setCustomerInputValue, @@ -92,14 +78,43 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { loadMore: loadMoreCustomers, } = useSelect(CustomerApi.basePath, 'id', 'name', 'search'); - // TODO: Uncomment when BE is ready - // const { - // options: salesOptions, - // setInputValue: setSalesInputValue, - // isLoadingOptions: isLoadingSales, - // loadMore: loadMoreSales, - // hasMore: hasMoreSales, - // } = useSelect(UserApi.basePath, 'id', 'name', 'search'); + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + start_date: null, + end_date: null, + customer_ids: null, + filter_by: null, + }, + validationSchema: CustomerPaymentFilterSchema, + onSubmit: (values, { setSubmitting }) => { + setFilterParams({ + start_date: values.start_date || undefined, + end_date: values.end_date || undefined, + customer_ids: values.customer_ids || undefined, + filter_by: values.filter_by || undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setCurrentPage(1); + setSubmitting(false); + }, + onReset: () => { + setFilterParams({}); + setIsSubmitted(false); + setCurrentPage(1); + setHasDateError(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }, + }); const getPaymentStatusColor = (notes: string) => { const normalizedValue = notes.toLowerCase(); @@ -137,63 +152,15 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { .join(' '); }; - // ===== FILTER HANDLERS ===== - const handleFilterModalOpen = useCallback(() => { - setFilterCustomer(appliedFilterCustomer); - // setFilterSales(appliedFilterSales); - setFilterByType(appliedFilterByType); - setFilterStartDate(appliedFilterStartDate); - setFilterEndDate(appliedFilterEndDate); - filterModal.openModal(); - }, [ - filterModal, - appliedFilterCustomer, - appliedFilterByType, - appliedFilterStartDate, - appliedFilterEndDate, - ]); - - const handleResetFilters = useCallback(() => { - setIsSubmitted(false); - setFilterCustomer([]); - setFilterByType(null); - setFilterStartDate(''); - setFilterEndDate(''); - setAppliedFilterCustomer([]); - setAppliedFilterByType(null); - setAppliedFilterStartDate(''); - setAppliedFilterEndDate(''); - setHasDateError(false); - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(false); - } - }, [dateErrorShown]); - - const handleApplyFilters = useCallback(() => { - setAppliedFilterCustomer(filterCustomer); - setAppliedFilterByType(filterByType); - setAppliedFilterStartDate(filterStartDate); - setAppliedFilterEndDate(filterEndDate); - setIsSubmitted(true); - setCurrentPage(1); - filterModal.closeModal(); - }, [ - filterModal, - filterCustomer, - filterByType, - filterStartDate, - filterEndDate, - ]); - + // ===== DATE CHANGE HANDLERS ===== const handleStartDateChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; - setFilterStartDate(value); + formik.setFieldValue('start_date', value || null); - if (value && filterEndDate) { + if (value && formik.values.end_date) { const startDate = new Date(value); - const endDateObj = new Date(filterEndDate); + const endDateObj = new Date(formik.values.end_date); if (endDateObj < startDate) { setHasDateError(true); @@ -214,16 +181,16 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setHasDateError(false); } }, - [filterEndDate, dateErrorShown] + [formik, dateErrorShown] ); const handleEndDateChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; - setFilterEndDate(value); + formik.setFieldValue('end_date', value || null); - if (value && filterStartDate) { - const startDateObj = new Date(filterStartDate); + if (value && formik.values.start_date) { + const startDateObj = new Date(formik.values.start_date); const endDate = new Date(value); if (endDate < startDateObj) { @@ -244,41 +211,46 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setDateErrorShown(false); } }, - [filterStartDate, dateErrorShown] + [formik, dateErrorShown] ); + // ===== FILTER HELPERS ===== + const customerIdsValue = useMemo(() => { + if (!formik.values.customer_ids) return []; + return customerOptions.filter((opt) => + formik.values.customer_ids?.split(',').includes(String(opt.value)) + ); + }, [formik.values.customer_ids, customerOptions]); + + const filterByValue = useMemo(() => { + if (!formik.values.filter_by) return null; + return ( + dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) || + null + ); + }, [formik.values.filter_by]); + // ===== ACTIVE FILTERS COUNT ===== const activeFiltersCount = useMemo(() => { let count = 0; // Date filter (start_date + end_date = 1 filter) - if (appliedFilterStartDate || appliedFilterEndDate) { + if (filterParams.start_date || filterParams.end_date) { count += 1; } // Customer filter - if (appliedFilterCustomer.length > 0) { + if (filterParams.customer_ids) { count += 1; } // Filter by type filter (hanya dihitung jika ada nilai yang dipilih) - if (appliedFilterByType) { + if (filterParams.filter_by) { count += 1; } - // TODO: Uncomment when BE is ready - // // Sales filter - // if (appliedFilterSales.length > 0) { - // count += 1; - // } - return count; - }, [ - appliedFilterStartDate, - appliedFilterEndDate, - appliedFilterCustomer, - appliedFilterByType, - ]); + }, [filterParams]); const hasFilters = activeFiltersCount > 0; @@ -287,21 +259,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { isSubmitted ? () => { const params = { - customer_ids: - appliedFilterCustomer.length > 0 - ? appliedFilterCustomer.map((v) => String(v.value)).join(',') - : undefined, - // TODO: Uncomment when BE is ready - // sales_id: - // appliedFilterSales.length > 0 - // ? appliedFilterSales.map((v) => String(v.value)).join(',') - // : undefined, - filter_by: appliedFilterByType?.value as + customer_ids: filterParams.customer_ids, + filter_by: filterParams.filter_by as | 'trans_date' | 'realization_date' | undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, + start_date: filterParams.start_date, + end_date: filterParams.end_date, page: currentPage, limit: pageSize, }; @@ -333,21 +297,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { CustomerPaymentReport[] | null > => { const params = { - customer_ids: - appliedFilterCustomer.length > 0 - ? appliedFilterCustomer.map((v) => String(v.value)).join(',') - : undefined, - // TODO: Uncomment when BE is ready - // sales_id: - // appliedFilterSales.length > 0 - // ? appliedFilterSales.map((v) => String(v.value)).join(',') - // : undefined, - filter_by: appliedFilterByType?.value as + customer_ids: filterParams.customer_ids, + filter_by: filterParams.filter_by as | 'trans_date' | 'realization_date' | undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, + start_date: filterParams.start_date, + end_date: filterParams.end_date, limit: 100, page: 1, }; @@ -364,13 +320,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { return isResponseSuccess(response) ? (response.data as unknown as CustomerPaymentReport[]) : null; - }, [ - appliedFilterCustomer, - // appliedFilterSales, - appliedFilterStartDate, - appliedFilterEndDate, - appliedFilterByType, - ]); + }, [filterParams]); // ===== EXPORT HANDLERS ===== const handleExportExcel = useCallback(async () => { @@ -410,21 +360,22 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { return; } + const customerName = filterParams.customer_ids + ? customerOptions + .filter((opt) => + filterParams.customer_ids?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Customer' + : 'Semua Customer'; + await generateCustomerPaymentPDF({ data: allDataForExport, params: { - customer_name: - appliedFilterCustomer.length > 0 - ? appliedFilterCustomer.map((c) => c.label).join(', ') - : undefined, - // TODO: Uncomment when BE is ready - // sales: - // appliedFilterSales.length > 0 - // ? appliedFilterSales.map((s) => s.label).join(', ') - // : undefined, - start_date: appliedFilterStartDate || undefined, - end_date: appliedFilterEndDate || undefined, - filter_by: appliedFilterByType?.value as + customer_name: customerName, + start_date: filterParams.start_date, + end_date: filterParams.end_date, + filter_by: filterParams.filter_by as | 'trans_date' | 'realization_date' | undefined, @@ -436,7 +387,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } finally { setIsPdfExportLoading(false); } - }, [customerPaymentExport]); + }, [customerPaymentExport, filterParams, customerOptions]); // ===== REGISTER TAB ACTIONS TO STORE ===== const setTabActions = useFinanceTabStore((state) => state.setTabActions); @@ -517,7 +468,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setTabActions, ]); - // Cleanup on unmount useEffect(() => { return () => { clearTabActions(tabId); @@ -931,95 +881,86 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => {
-
-
- -
- -
+
+
+
+ +
+ +
- + +
+ + { + formik.setFieldValue( + 'customer_ids', + Array.isArray(val) && val.length > 0 + ? val.map((v: OptionType) => String(v.value)).join(',') + : null + ); + }} + onInputChange={setCustomerInputValue} + isLoading={isLoadingCustomers} + isClearable + onMenuScrollToBottom={loadMoreCustomers} + className={{ wrapper: 'w-full' }} + /> + + { + if (!Array.isArray(val)) { + formik.setFieldValue('filter_by', val?.value || null); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + />
- { - setFilterCustomer(Array.isArray(val) ? val : val ? [val] : []); - }} - onInputChange={setCustomerInputValue} - isLoading={isLoadingCustomers} - isClearable - onMenuScrollToBottom={loadMoreCustomers} - className={{ wrapper: 'w-full' }} - /> - - {/* TODO: Uncomment when BE is ready */} - {/*
- { - setFilterSales(Array.isArray(val) ? val : val ? [val] : []); - }} - onInputChange={setSalesInputValue} - isLoading={isLoadingSales} - isClearable - onMenuScrollToBottom={loadMoreSales} - className={{ wrapper: 'w-full' }} - /> -
*/} - - { - if (val && !Array.isArray(val)) { - setFilterByType(val); - } - }} - className={{ wrapper: 'w-full' }} - /> - - {/* Action Buttons */} -
-
- - -
+ {/* Modal Footer */} +
+ + +
+ ); From e23b53d7979d6eb64bab5afd6c98c113d041b548 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 10:46:06 +0700 Subject: [PATCH 09/36] refactor(FE): Refactor CustomerPaymentTab to use StatusBadge component --- .../report/finance/tab/CustomerPaymentTab.tsx | 60 ++++++------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index 723a1ebf..a0fb63ae 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import useSWR from 'swr'; import { Icon } from '@iconify/react'; import Card from '@/components/Card'; -import Badge from '@/components/Badge'; +import StatusBadge from '@/components/helper/StatusBadge'; import { useSelect } from '@/components/input/SelectInput'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; @@ -11,7 +11,13 @@ import { CustomerApi } from '@/services/api/master-data'; import { FinanceApi } from '@/services/api/report/finance-report'; import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; -import { formatCurrency, formatDate, formatNumber, cn } from '@/lib/helper'; +import { + formatCurrency, + formatDate, + formatNumber, + formatTitleCase, + cn, +} from '@/lib/helper'; import { CustomerPaymentReport, CustomerPaymentSummary, @@ -33,6 +39,7 @@ import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/ex import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import { OptionType } from '@/components/table/TableRowSizeSelector'; +import { Color } from '@/types/theme'; interface CustomerPaymentTabProps { tabId: string; @@ -116,40 +123,18 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { }, }); - const getPaymentStatusColor = (notes: string) => { + const getPaymentStatusBadgeColor = (notes: string): Color => { const normalizedValue = notes.toLowerCase(); if (normalizedValue === 'lunas') { - return 'bg-info/10 text-black border-info'; + return 'primary'; } if (normalizedValue.includes('belum')) { - return 'bg-warning/10 text-black border-warning'; + return 'warning'; } - return 'bg-gray-100 text-black border-gray-300'; - }; - - const getPaymentStatusIndicatorColor = (notes: string) => { - const normalizedValue = notes.toLowerCase(); - - if (normalizedValue === 'lunas') { - return 'bg-info'; - } - - if (normalizedValue.includes('belum')) { - return 'bg-warning'; - } - - return 'bg-gray-400'; - }; - - const getPaymentStatusText = (notes: string) => { - return notes - .toLowerCase() - .split(' ') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); + return 'neutral'; }; // ===== DATE CHANGE HANDLERS ===== @@ -181,7 +166,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setHasDateError(false); } }, - [formik, dateErrorShown] + [dateErrorShown] ); const handleEndDateChange = useCallback( @@ -211,7 +196,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setDateErrorShown(false); } }, - [formik, dateErrorShown] + [dateErrorShown] ); // ===== FILTER HELPERS ===== @@ -685,17 +670,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } return ( - - {getPaymentStatusText(value)} - + ); }, }, From 322b519def4db1b92a3aee81863db64092f24ba8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 10:55:40 +0700 Subject: [PATCH 10/36] refactor(FE): Refactor filter schema and form handling for PurchasesPerSupplier --- .../filter/PurchasesPerSupplierFilter.ts | 110 ++++---------- .../tab/PurchasesPerSupplierTab.tsx | 137 ++++++++++-------- 2 files changed, 106 insertions(+), 141 deletions(-) diff --git a/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts index 70a01615..b3d9943b 100644 --- a/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts +++ b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts @@ -1,88 +1,38 @@ -import { OptionType } from '@/components/input/SelectInput'; import * as yup from 'yup'; export type PurchasesPerSupplierFilterType = { - start_date: string | null | undefined; - end_date: string | null | undefined; - area_ids: OptionType[] | null | undefined; - supplier_ids: OptionType[] | null | undefined; - product_ids: OptionType[] | null | undefined; - product_category_ids: OptionType[] | null | undefined; - filter_by: OptionType | null | undefined; - sort_by: OptionType | null | undefined; + start_date: string | null; + end_date: string | null; + area_ids: string | null; + supplier_ids: string | null; + product_ids: string | null; + product_category_ids: string | null; + filter_by: string | null; + sort_by: string | null; }; -export const PurchasesPerSupplierFilterSchema: yup.ObjectSchema = - yup.object({ - start_date: yup.string().optional().nullable(), - end_date: yup - .string() - .optional() - .nullable() - .test( - 'is-greater-than-start', - 'Tanggal akhir tidak boleh masa lampau', - function (value) { - const { start_date } = this.parent; - if (!start_date || !value) return true; - return new Date(value) >= new Date(start_date); - } - ), - area_ids: yup - .array() - .of( - yup.object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - ) - .optional() - .nullable(), - supplier_ids: yup - .array() - .of( - yup.object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - ) - .optional() - .nullable(), - product_ids: yup - .array() - .of( - yup.object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - ) - .optional() - .nullable(), - product_category_ids: yup - .array() - .of( - yup.object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - ) - .optional() - .nullable(), - filter_by: yup - .object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - .optional() - .nullable(), - sort_by: yup - .object({ - value: yup.mixed().required(), - label: yup.string().required(), - }) - .optional() - .nullable(), - }); +export const PurchasesPerSupplierFilterSchema = yup.object({ + start_date: yup.string().optional().nullable(), + end_date: yup + .string() + .optional() + .nullable() + .test( + 'is-greater-than-start', + 'Tanggal akhir tidak boleh masa lampau', + function (value) { + const { start_date } = this.parent; + if (!start_date || !value) return true; + return new Date(value) >= new Date(start_date); + } + ), + area_ids: yup.string().nullable(), + supplier_ids: yup.string().nullable(), + product_ids: yup.string().nullable(), + product_category_ids: yup.string().nullable(), + filter_by: yup.string().nullable(), + sort_by: yup.string().nullable(), +}); export type PurchasesPerSupplierFilterValues = yup.InferType< typeof PurchasesPerSupplierFilterSchema diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 794c45d6..23fb067e 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -125,21 +125,14 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { validationSchema: PurchasesPerSupplierFilterSchema, onSubmit: (values, { setSubmitting }) => { setFilterParams({ - start_date: values.start_date?.toString() || undefined, - end_date: values.end_date?.toString() || undefined, - area_id: - values.area_ids?.map((v) => String(v.value)).join(',') || undefined, - supplier_id: - values.supplier_ids?.map((v) => String(v.value)).join(',') || - undefined, - product_id: - values.product_ids?.map((v) => String(v.value)).join(',') || - undefined, - product_category_id: - values.product_category_ids?.map((v) => String(v.value)).join(',') || - undefined, - filter_by: values.filter_by?.value?.toString() || undefined, - sort_by: values.sort_by?.value?.toString() || undefined, + start_date: values.start_date || undefined, + end_date: values.end_date || undefined, + area_id: values.area_ids || undefined, + supplier_id: values.supplier_ids || undefined, + product_id: values.product_ids || undefined, + product_category_id: values.product_category_ids || undefined, + filter_by: values.filter_by || undefined, + sort_by: values.sort_by || undefined, }); filterModal.closeModal(); setIsSubmitted(true); @@ -220,6 +213,48 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { [formik, dateErrorShown] ); + // ===== DERIVED VALUES ===== + const areaIdsValue = useMemo(() => { + if (!formik.values.area_ids) return []; + const ids = formik.values.area_ids.split(','); + return areaOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.area_ids, areaOptions]); + + const supplierIdsValue = useMemo(() => { + if (!formik.values.supplier_ids) return []; + const ids = formik.values.supplier_ids.split(','); + return supplierOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.supplier_ids, supplierOptions]); + + const productIdsValue = useMemo(() => { + if (!formik.values.product_ids) return []; + const ids = formik.values.product_ids.split(','); + return productOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.product_ids, productOptions]); + + const productCategoryIdsValue = useMemo(() => { + if (!formik.values.product_category_ids) return []; + const ids = formik.values.product_category_ids.split(','); + return productCategoryOptions.filter((opt) => + ids.includes(String(opt.value)) + ); + }, [formik.values.product_category_ids, productCategoryOptions]); + + const filterByValue = useMemo(() => { + if (!formik.values.filter_by) return null; + return ( + dataTypeOptions.find((opt) => opt.value === formik.values.filter_by) || + null + ); + }, [formik.values.filter_by, dataTypeOptions]); + + const sortByValue = useMemo(() => { + if (!formik.values.sort_by) return null; + return ( + sortByOptions.find((opt) => opt.value === formik.values.sort_by) || null + ); + }, [formik.values.sort_by, sortByOptions]); + // ===== ACTIVE FILTERS COUNT ===== const activeFiltersCount = useMemo(() => { let count = 0; @@ -875,17 +910,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Area' placeholder='Pilih Area' options={areaOptions} - value={ - (formik.values.area_ids as - | { value: number; label: string } - | { value: number; label: string }[] - | null - | undefined) || [] - } + value={areaIdsValue} onChange={(val) => { formik.setFieldValue( 'area_ids', - Array.isArray(val) ? val : val ? [val] : null + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null ); }} isLoading={isLoadingAreas} @@ -898,17 +929,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Supplier' placeholder='Pilih Supplier' options={supplierOptions} - value={ - (formik.values.supplier_ids as - | { value: number; label: string } - | { value: number; label: string }[] - | null - | undefined) || [] - } + value={supplierIdsValue} onChange={(val) => { formik.setFieldValue( 'supplier_ids', - Array.isArray(val) ? val : val ? [val] : null + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null ); }} isLoading={isLoadingSuppliers} @@ -921,17 +948,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Produk' placeholder='Pilih Produk' options={productOptions} - value={ - (formik.values.product_ids as - | { value: number; label: string } - | { value: number; label: string }[] - | null - | undefined) || [] - } + value={productIdsValue} onChange={(val) => { formik.setFieldValue( 'product_ids', - Array.isArray(val) ? val : val ? [val] : null + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null ); }} isLoading={isLoadingProducts} @@ -944,17 +967,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Kategori Produk' placeholder='Pilih Kategori Produk' options={productCategoryOptions} - value={ - (formik.values.product_category_ids as - | { value: number; label: string } - | { value: number; label: string }[] - | null - | undefined) || [] - } + value={productCategoryIdsValue} onChange={(val) => { formik.setFieldValue( 'product_category_ids', - Array.isArray(val) ? val : val ? [val] : null + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null ); }} isLoading={isLoadingProductCategories} @@ -967,15 +986,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Filter Berdasarkan' placeholder='Pilih Filter Berdasarkan' options={dataTypeOptions} - value={ - (formik.values.filter_by as - | { value: string; label: string } - | null - | undefined) || null - } + value={filterByValue} onChange={(val) => { if (!Array.isArray(val)) { - formik.setFieldValue('filter_by', val); + formik.setFieldValue( + 'filter_by', + val?.value?.toString() || null + ); } }} className={{ wrapper: 'w-full' }} @@ -987,15 +1004,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { label='Urutkan Berdasarkan' placeholder='Pilih Urutkan Berdasarkan' options={sortByOptions} - value={ - (formik.values.sort_by as - | { value: string; label: string } - | null - | undefined) || null - } + value={sortByValue} onChange={(val) => { if (!Array.isArray(val)) { - formik.setFieldValue('sort_by', val); + formik.setFieldValue( + 'sort_by', + val?.value?.toString() || null + ); } }} className={{ wrapper: 'w-full' }} From ee53ea61ccbee946516d9942b458c1cfcf71bee4 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 10:57:22 +0700 Subject: [PATCH 11/36] refactor(FE): Fix missing dependency in useCallback hooks --- .../pages/report/finance/tab/CustomerPaymentTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index a0fb63ae..3680a41c 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -166,7 +166,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setHasDateError(false); } }, - [dateErrorShown] + [formik, dateErrorShown] ); const handleEndDateChange = useCallback( @@ -196,7 +196,7 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setDateErrorShown(false); } }, - [dateErrorShown] + [formik, dateErrorShown] ); // ===== FILTER HELPERS ===== From 6d2855d1173c373fdfe3411ff4b3d2d150580fd8 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 11:15:42 +0700 Subject: [PATCH 12/36] refactor(FE): Add zustand store for marketing tab actions --- .../marketing-tab/marketing-tab.store.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/stores/marketing-tab/marketing-tab.store.ts diff --git a/src/stores/marketing-tab/marketing-tab.store.ts b/src/stores/marketing-tab/marketing-tab.store.ts new file mode 100644 index 00000000..153bbb8d --- /dev/null +++ b/src/stores/marketing-tab/marketing-tab.store.ts @@ -0,0 +1,51 @@ +'use client'; + +import { ReactNode } from 'react'; +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export type MarketingTabActionsSlice = { + // State - actions per tab ID + tabActions: Record; + + // Actions + setTabActions: (tabId: string, actions: ReactNode) => void; + clearTabActions: (tabId: string) => void; + clearAllTabActions: () => void; +}; + +export const useMarketingTabStore = create()( + devtools( + (set) => ({ + tabActions: {}, + + setTabActions: (tabId, actions) => + set( + (state) => ({ + tabActions: { + ...state.tabActions, + [tabId]: actions, + }, + }), + false, + 'setTabActions' + ), + + clearTabActions: (tabId) => + set( + (state) => { + const { [tabId]: _, ...rest } = state.tabActions; + return { tabActions: rest }; + }, + false, + 'clearTabActions' + ), + + clearAllTabActions: () => + set({ tabActions: {} }, false, 'clearAllTabActions'), + }), + { + name: 'MarketingTabStore', + } + ) +); From 43d26b4833d16d99c857c54e33735e4410dde72c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 11:16:26 +0700 Subject: [PATCH 13/36] refactor(FE): Refactor marketing report components and add HPP filter --- src/app/report/marketing/page.tsx | 6 +- .../marketing/MarketingReportContent.tsx | 59 +- .../marketing/filter/HppPerKandangFilter.ts | 40 + .../skeleton/HppPerKandangSkeleton.tsx | 37 + .../report/marketing/tab/HppPerKandangTab.tsx | 919 +++++++++++------- 5 files changed, 658 insertions(+), 403 deletions(-) create mode 100644 src/components/pages/report/marketing/filter/HppPerKandangFilter.ts create mode 100644 src/components/pages/report/marketing/skeleton/HppPerKandangSkeleton.tsx diff --git a/src/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx index 87ed7a1a..57035844 100644 --- a/src/app/report/marketing/page.tsx +++ b/src/app/report/marketing/page.tsx @@ -1,11 +1,7 @@ import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent'; const MarketingReportPage = () => { - return ( -
- -
- ); + return ; }; export default MarketingReportPage; diff --git a/src/components/pages/report/marketing/MarketingReportContent.tsx b/src/components/pages/report/marketing/MarketingReportContent.tsx index e38a39d4..9277311f 100644 --- a/src/components/pages/report/marketing/MarketingReportContent.tsx +++ b/src/components/pages/report/marketing/MarketingReportContent.tsx @@ -1,47 +1,42 @@ 'use client'; -import { JSX, useState } from 'react'; - +import { useState } from 'react'; import Tabs from '@/components/Tabs'; import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent'; import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; - -type MarketingReportTabType = - | 'daily' - | 'transaction' - | 'hpp-comparison' - | 'daily-hpp'; - -const marketingReportTabs: { - id: MarketingReportTabType; - label: string; - content: JSX.Element; -}[] = [ - { - id: 'daily', - label: 'Penjualan Harian', - content: , - }, - { - id: 'daily-hpp', - label: 'HPP Harian Kandang', - content: , - }, -]; +import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; const MarketingReportContent = () => { - const [activeTab, setActiveTab] = useState('daily'); + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useMarketingTabStore((state) => state.tabActions); + + const tabs = [ + { + id: '1', + label: 'Penjualan Harian', + content: , + }, + { + id: '2', + label: 'HPP Harian Kandang', + content: , + }, + ]; return ( -
+
); diff --git a/src/components/pages/report/marketing/filter/HppPerKandangFilter.ts b/src/components/pages/report/marketing/filter/HppPerKandangFilter.ts new file mode 100644 index 00000000..57d2dcd2 --- /dev/null +++ b/src/components/pages/report/marketing/filter/HppPerKandangFilter.ts @@ -0,0 +1,40 @@ +import * as yup from 'yup'; + +export type HppPerKandangFilterType = { + area_id: string | null; + location_id: string | null; + kandang_id: string | null; + weight_min: string | null; + weight_max: string | null; + period: string | null; + sort_by: string | null; + show_unrecorded: boolean | null; +}; + +export const HppPerKandangFilterSchema = yup.object({ + area_id: yup.string().nullable(), + location_id: yup.string().nullable(), + kandang_id: yup.string().nullable(), + weight_min: yup.string().nullable(), + weight_max: yup + .string() + .nullable() + .test( + 'is-greater-than-min', + 'Rentang bobot max tidak boleh lebih kecil dari min', + function (value) { + const { weight_min } = this.parent; + if (!weight_min || !value) return true; + const weightMinNum = parseFloat(weight_min) || 0; + const weightMaxNum = parseFloat(value) || 0; + return weightMaxNum >= weightMinNum; + } + ), + period: yup.string().required('Periode wajib diisi'), + sort_by: yup.string().nullable(), + show_unrecorded: yup.boolean().nullable(), +}); + +export type HppPerKandangFilterValues = yup.InferType< + typeof HppPerKandangFilterSchema +>; diff --git a/src/components/pages/report/marketing/skeleton/HppPerKandangSkeleton.tsx b/src/components/pages/report/marketing/skeleton/HppPerKandangSkeleton.tsx new file mode 100644 index 00000000..42a6cf56 --- /dev/null +++ b/src/components/pages/report/marketing/skeleton/HppPerKandangSkeleton.tsx @@ -0,0 +1,37 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang'; +import { ColumnDef } from '@tanstack/react-table'; + +const HppPerKandangSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default HppPerKandangSkeleton; diff --git a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx index c0371abf..514edcb9 100644 --- a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx +++ b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx @@ -1,11 +1,6 @@ import { useState, useMemo, useCallback } from 'react'; -import { ChangeEventHandler } from 'react'; import useSWR from 'swr'; -import Card from '@/components/Card'; -import SelectInput, { - useSelect, - OptionType, -} from '@/components/input/SelectInput'; +import { useSelect } from '@/components/input/SelectInput'; import DateInput from '@/components/input/DateInput'; import NumberInput from '@/components/input/NumberInput'; import { AreaApi } from '@/services/api/master-data'; @@ -21,7 +16,6 @@ import { HppPerKandangPerWeightRange, } from '@/types/api/report/hpp-per-kandang'; import { isResponseSuccess } from '@/lib/api-helper'; -import { useTableFilter } from '@/services/hooks/useTableFilter'; import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; import MenuItem from '@/components/menu/MenuItem'; @@ -30,8 +24,35 @@ import { generateHppPerKandangPDF } from '@/components/pages/report/marketing/ex import { generateHppPerKandangExcel } from '@/components/pages/report/marketing/export/HppPerkandangExportXLSX'; import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; +import { useFormik } from 'formik'; +import { + HppPerKandangFilterSchema, + HppPerKandangFilterType, +} from '@/components/pages/report/marketing/filter/HppPerKandangFilter'; +import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; +import SelectInputRadio from '@/components/input/SelectInputRadio'; +import Modal, { useModal } from '@/components/Modal'; +import { cn } from '@/lib/helper'; +import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; +import HppPerKandangSkeleton from '@/components/pages/report/marketing/skeleton/HppPerKandangSkeleton'; +import { useEffect as useEffectHook } from 'react'; -const HppPerKandangTab = () => { +interface HppPerKandangTabProps { + tabId: string; +} + +interface FilterParams { + area_id?: string; + location_id?: string; + kandang_id?: string; + weight_min?: string; + weight_max?: string; + period?: string; + sort_by?: string; + show_unrecorded?: boolean; +} + +const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); @@ -42,190 +63,229 @@ const HppPerKandangTab = () => { // ===== VALIDATION STATE ===== const [weightMaxError, setWeightMaxError] = useState(''); + const [dateErrorShown, setDateErrorShown] = useState(false); - // ===== TABLE FILTER STATE ===== - const { state: tableFilterState, updateFilter } = useTableFilter({ - initial: { - area_id: [] as string[], - location_id: [] as string[], - kandang_id: [] as string[], - weight_min: '', - weight_max: '', - period: '', - sort_by: '', - show_unrecorded: false, - }, - paramMap: { - page: 'page', - pageSize: 'limit', - }, - }); + // ===== FILTER STATE ===== + const [filterParams, setFilterParams] = useState({}); - const { - setInputValue: setAreaInputValue, - options: areaOptions, - isLoadingOptions: isLoadingAreas, - loadMore: loadMoreAreas, - } = useSelect(AreaApi.basePath, 'id', 'name', 'search'); + const filterModal = useModal(); - const { - setInputValue: setLocationInputValue, - options: locationOptions, - isLoadingOptions: isLoadingLocations, - loadMore: loadMoreLocations, - } = useSelect(LocationApi.basePath, 'id', 'name', 'search'); - - const { - setInputValue: setKandangInputValue, - options: kandangOptions, - isLoadingOptions: isLoadingKandangs, - loadMore: loadMoreKandangs, - } = useSelect( - ProjectFlockKandangApi.basePath, + // ===== OPTIONS ===== + const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( + AreaApi.basePath, 'id', - 'name_with_period', + 'name', 'search' ); - const showUnrecordedOptions: OptionType[] = [ - { value: 'false', label: 'Sembunyikan' }, - { value: 'true', label: 'Tampilkan' }, - ]; + const { options: locationOptions, isLoadingOptions: isLoadingLocations } = + useSelect(LocationApi.basePath, 'id', 'name', 'search'); - const areaChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'area_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); - }, - [updateFilter] + const { options: kandangOptions, isLoadingOptions: isLoadingKandangs } = + useSelect( + ProjectFlockKandangApi.basePath, + 'id', + 'name_with_period', + 'search' + ); + + const showUnrecordedOptions = useMemo( + () => [ + { value: 'false', label: 'Sembunyikan' }, + { value: 'true', label: 'Tampilkan' }, + ], + [] ); - const locationChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'location_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + area_id: null, + location_id: null, + kandang_id: null, + weight_min: null, + weight_max: null, + period: null, + sort_by: null, + show_unrecorded: null, }, - [updateFilter] - ); - - const kandangChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const arr = Array.isArray(val) ? val : val ? [val] : []; - updateFilter( - 'kandang_id', - arr.map((v) => String((v as OptionType).value)) - ); - setIsSubmitted(false); + validationSchema: HppPerKandangFilterSchema, + onSubmit: (values, { setSubmitting }) => { + setFilterParams({ + area_id: values.area_id || undefined, + location_id: values.location_id || undefined, + kandang_id: values.kandang_id || undefined, + weight_min: values.weight_min || undefined, + weight_max: values.weight_max || undefined, + period: values.period || undefined, + sort_by: values.sort_by || undefined, + show_unrecorded: + values.show_unrecorded !== null ? values.show_unrecorded : undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setSubmitting(false); }, - [updateFilter] - ); - - const weightMinChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - updateFilter('weight_min', val ? String(parseFloat(val) || 0) : ''); + onReset: () => { + setFilterParams({}); setIsSubmitted(false); + setWeightMaxError(''); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + }, + }); - if (weightMaxError) { + // ===== WEIGHT CHANGE HANDLERS ===== + const handleWeightMinChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('weight_min', value || null); + + if (value && formik.values.weight_max) { + const weightMin = parseFloat(value) || 0; + const weightMax = parseFloat(formik.values.weight_max) || 0; + + if (weightMax < weightMin) { + setWeightMaxError( + 'Rentang bobot max tidak boleh lebih kecil dari min' + ); + if (!dateErrorShown) { + toast.error('Rentang bobot max tidak boleh lebih kecil dari min', { + duration: Infinity, + }); + setDateErrorShown(true); + } + } else { + setWeightMaxError(''); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } + } + } else { setWeightMaxError(''); } }, - [updateFilter, weightMaxError] + [formik, dateErrorShown] ); - const weightMaxChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - const weightMax = val ? parseFloat(val) || 0 : 0; - const weightMin = tableFilterState.weight_min - ? parseFloat(tableFilterState.weight_min) - : 0; + const handleWeightMaxChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('weight_max', value || null); - if (weightMax < weightMin) { - setWeightMaxError('Rentang bobot max tidak boleh lebih kecil dari min'); - toast.error('Rentang bobot max tidak boleh lebih kecil dari min'); - return; + if (value && formik.values.weight_min) { + const weightMin = parseFloat(formik.values.weight_min) || 0; + const weightMax = parseFloat(value) || 0; + + if (weightMax < weightMin) { + setWeightMaxError( + 'Rentang bobot max tidak boleh lebih kecil dari min' + ); + if (!dateErrorShown) { + toast.error('Rentang bobot max tidak boleh lebih kecil dari min', { + duration: Infinity, + }); + setDateErrorShown(true); + } + return; + } } setWeightMaxError(''); - updateFilter('weight_max', val ? String(weightMax) : ''); - setIsSubmitted(false); + if (dateErrorShown) { + toast.dismiss(); + setDateErrorShown(false); + } }, - [updateFilter, tableFilterState.weight_min] + [formik, dateErrorShown] ); - const periodChangeHandler = useCallback>( - (e) => { - const val = e.target.value; - updateFilter('period', val || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); + // ===== DERIVED VALUES ===== + const areaIdsValue = useMemo(() => { + if (!formik.values.area_id) return []; + const ids = formik.values.area_id.split(','); + return areaOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.area_id, areaOptions]); - const showUnrecordedChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const newVal = val as OptionType; - updateFilter('show_unrecorded', newVal?.value === 'true'); - setIsSubmitted(false); - }, - [updateFilter] - ); + const locationIdsValue = useMemo(() => { + if (!formik.values.location_id) return []; + const ids = formik.values.location_id.split(','); + return locationOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.location_id, locationOptions]); - const resetFilters = useCallback(() => { - updateFilter('area_id', []); - updateFilter('location_id', []); - updateFilter('kandang_id', []); - updateFilter('weight_min', ''); - updateFilter('weight_max', ''); - updateFilter('period', ''); - updateFilter('sort_by', ''); - updateFilter('show_unrecorded', false); - setIsSubmitted(false); - }, [updateFilter]); + const kandangIdsValue = useMemo(() => { + if (!formik.values.kandang_id) return []; + const ids = formik.values.kandang_id.split(','); + return kandangOptions.filter((opt) => ids.includes(String(opt.value))); + }, [formik.values.kandang_id, kandangOptions]); - const handleSubmit = useCallback(() => { - if (!tableFilterState.period) { - toast.error('Periode wajib diisi'); - return; + const showUnrecordedValue = useMemo(() => { + if (formik.values.show_unrecorded === null) return null; + return ( + showUnrecordedOptions.find( + (opt) => opt.value === String(formik.values.show_unrecorded) + ) || null + ); + }, [formik.values.show_unrecorded, showUnrecordedOptions]); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + if (filterParams.period) { + count += 1; } - setIsSubmitted(true); - }, [tableFilterState.period]); + + if (filterParams.area_id) { + count += 1; + } + + if (filterParams.location_id) { + count += 1; + } + + if (filterParams.kandang_id) { + count += 1; + } + + if (filterParams.weight_min || filterParams.weight_max) { + count += 1; + } + + if (filterParams.show_unrecorded !== undefined) { + count += 1; + } + + if (filterParams.sort_by) { + count += 1; + } + + return count; + }, [filterParams]); + + const hasFilters = activeFiltersCount > 0; // ===== DATA FETCHING ===== const { data: hppPerKandang, isLoading } = useSWR( isSubmitted ? () => { const params = { - area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') - : undefined, - location_id: - tableFilterState.location_id.length > 0 - ? tableFilterState.location_id.join(',') - : undefined, - kandang_id: - tableFilterState.kandang_id.length > 0 - ? tableFilterState.kandang_id.join(',') - : undefined, - weight_min: tableFilterState.weight_min || undefined, - weight_max: tableFilterState.weight_max || undefined, - period: tableFilterState.period || undefined, - sort_by: tableFilterState.sort_by || undefined, - show_unrecorded: tableFilterState.show_unrecorded, + area_id: filterParams.area_id, + location_id: filterParams.location_id, + kandang_id: filterParams.kandang_id, + weight_min: filterParams.weight_min, + weight_max: filterParams.weight_max, + period: filterParams.period, + sort_by: filterParams.sort_by, + show_unrecorded: filterParams.show_unrecorded, }; return ['hpp-per-kandang-report', params]; @@ -275,23 +335,14 @@ const HppPerKandangTab = () => { const hppPerKandangExport = useCallback(async (): Promise => { const params = { - area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') - : undefined, - location_id: - tableFilterState.location_id.length > 0 - ? tableFilterState.location_id.join(',') - : undefined, - kandang_id: - tableFilterState.kandang_id.length > 0 - ? tableFilterState.kandang_id.join(',') - : undefined, - weight_min: tableFilterState.weight_min || undefined, - weight_max: tableFilterState.weight_max || undefined, - period: tableFilterState.period || undefined, - sort_by: tableFilterState.sort_by || undefined, - show_unrecorded: tableFilterState.show_unrecorded, + area_id: filterParams.area_id, + location_id: filterParams.location_id, + kandang_id: filterParams.kandang_id, + weight_min: filterParams.weight_min, + weight_max: filterParams.weight_max, + period: filterParams.period, + sort_by: filterParams.sort_by, + show_unrecorded: filterParams.show_unrecorded, limit: 10000, page: 1, }; @@ -308,7 +359,7 @@ const HppPerKandangTab = () => { ); return isResponseSuccess(response) ? response.data : null; - }, [tableFilterState]); + }, [filterParams]); // ===== TABLE COLUMNS DEFINITION ===== const allFeedSuppliers = useMemo(() => { @@ -373,38 +424,32 @@ const HppPerKandangTab = () => { return; } - const areaName = - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id - .map( - (id) => - areaOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Area' - : 'Semua Area'; + const areaName = filterParams.area_id + ? areaOptions + .filter((opt) => + filterParams.area_id?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Area' + : 'Semua Area'; - const locationName = - tableFilterState.location_id.length > 0 - ? tableFilterState.location_id - .map( - (id) => - locationOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Lokasi' - : 'Semua Lokasi'; + const locationName = filterParams.location_id + ? locationOptions + .filter((opt) => + filterParams.location_id?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Lokasi' + : 'Semua Lokasi'; - const kandangName = - tableFilterState.kandang_id.length > 0 - ? tableFilterState.kandang_id - .map( - (id) => - kandangOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Kandang' - : 'Semua Kandang'; + const kandangName = filterParams.kandang_id + ? kandangOptions + .filter((opt) => + filterParams.kandang_id?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.label) + .join(', ') || 'Semua Kandang' + : 'Semua Kandang'; await generateHppPerKandangPDF( { @@ -413,11 +458,12 @@ const HppPerKandangTab = () => { area_name: areaName, location_name: locationName, kandang_name: kandangName, - period: tableFilterState.period, - weight_min: tableFilterState.weight_min, - weight_max: tableFilterState.weight_max, - show_unrecorded: tableFilterState.show_unrecorded.toString(), - sort_by: tableFilterState.sort_by, + period: filterParams.period, + weight_min: filterParams.weight_min, + weight_max: filterParams.weight_max, + show_unrecorded: + filterParams.show_unrecorded?.toString() || 'false', + sort_by: filterParams.sort_by, }, }, allFeedSuppliers, @@ -432,7 +478,7 @@ const HppPerKandangTab = () => { } }, [ hppPerKandangExport, - tableFilterState, + filterParams, areaOptions, locationOptions, kandangOptions, @@ -440,6 +486,91 @@ const HppPerKandangTab = () => { allDocSuppliers, ]); + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useMarketingTabStore((state) => state.setTabActions); + const clearTabActions = useMarketingTabStore( + (state) => state.clearTabActions + ); + + useEffectHook(() => { + setTabActions( + tabId, +
+ + + + + Export +
+ +
+ + } + align='end' + className={{ + content: + 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', + }} + > + + + + +
+
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + filterModal.open, + setTabActions, + ]); + + useEffectHook(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); + const getTableColumns = (): ColumnDef[] => { const tableColumns: ColumnDef[] = [ { @@ -682,161 +813,50 @@ const HppPerKandangTab = () => { ); return ( -
- HPP Harian Kandang (${period})` - : 'Laporan > HPP Harian Kandang' - } - className={{ wrapper: 'w-full', body: 'p-1!' }} - > -
- - (tableFilterState.area_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={areaChangeHandler} - onInputChange={setAreaInputValue} - onMenuScrollToBottom={loadMoreAreas} - isLoading={isLoadingAreas} - closeMenuOnSelect={false} - hideSelectedOptions={false} - isClearable - /> - - (tableFilterState.location_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={locationChangeHandler} - onInputChange={setLocationInputValue} - onMenuScrollToBottom={loadMoreLocations} - isLoading={isLoadingLocations} - closeMenuOnSelect={false} - hideSelectedOptions={false} - isClearable - /> - - (tableFilterState.kandang_id || []) - .map(String) - .includes(String(opt.value)) - )} - onChange={kandangChangeHandler} - onInputChange={setKandangInputValue} - onMenuScrollToBottom={loadMoreKandangs} - isLoading={isLoadingKandangs} - closeMenuOnSelect={false} - hideSelectedOptions={false} - isClearable - /> -
- -
-
- - -
- - opt.value === 'true') || - null - : showUnrecordedOptions.find((opt) => opt.value === 'false') || - null - } - onChange={showUnrecordedChangeHandler} - /> -
- -
- - - - Export - - - } - align='end' - > - - - - - -
- -
- + <> +
{!isSubmitted ? ( -
- Silakan pilih filter dan klik tombol Cari untuk menampilkan data. -
+ + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> ) : isLoading ? ( -
- -
+ + } + title='Memuat Data HPP Per Kandang' + subtitle='Silakan tunggu sebentar...' + /> ) : data.length === 0 ? ( -
- Tidak ada data yang dapat ditampilkan... -
+ + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> ) : (
{ renderFooter={data.length > 0} renderCustomRow={renderCustomRow} className={{ - containerClassName: 'w-full mt-6', - tableWrapperClassName: 'overflow-x-auto mt-4', + containerClassName: 'w-full mb-0!', + tableWrapperClassName: 'overflow-x-auto', tableClassName: 'w-full table-auto text-sm', headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', headerColumnClassName: @@ -862,8 +882,175 @@ const HppPerKandangTab = () => { }} /> )} - - + + + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+
+
+ {/* Period Filter */} +
+ { + formik.setFieldValue('period', e.target.value || null); + }} + className={{ wrapper: 'w-full' }} + isNestedModal + required={true} + isError={!!formik.errors.period && formik.touched.period} + /> + {formik.errors.period && formik.touched.period && ( +
+ {formik.errors.period} +
+ )} +
+ + {/* Area Filter */} + { + formik.setFieldValue( + 'area_id', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingAreas} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Location Filter */} + { + formik.setFieldValue( + 'location_id', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingLocations} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Kandang Filter */} + { + formik.setFieldValue( + 'kandang_id', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingKandangs} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Weight Range Filter */} +
+ +
+ +
+ +
+
+ + {/* Show Unrecorded Filter */} + { + if (!Array.isArray(val)) { + formik.setFieldValue( + 'show_unrecorded', + val?.value === 'true' || null + ); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ ); }; From 5e4619fac7cdee9f516509e28c23c69456d3d2a3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 11:38:46 +0700 Subject: [PATCH 14/36] feat(FE): Add DailyMarketingReportFilter and DailyMarketingReportSkeleton components --- .../filter/DailyMarketingReportFilter.ts | 42 +++++++++++++++++++ .../skeleton/DailyMarketingReportSkeleton.tsx | 37 ++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts create mode 100644 src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx diff --git a/src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts b/src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts new file mode 100644 index 00000000..85c765a9 --- /dev/null +++ b/src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts @@ -0,0 +1,42 @@ +import * as yup from 'yup'; + +export type DailyMarketingReportFilterType = { + search: string | null; + area_id: string | null; + location_id: string | null; + warehouse_id: string | null; + customer_id: string | null; + start_date: string | null; + end_date: string | null; + marketing_type: string | null; + filter_by: string | null; + sort_by: string | null; +}; + +export const DailyMarketingReportFilterSchema = yup.object({ + search: yup.string().nullable(), + area_id: yup.string().nullable(), + location_id: yup.string().nullable(), + warehouse_id: yup.string().nullable(), + customer_id: yup.string().nullable(), + start_date: yup.string().nullable(), + end_date: yup + .string() + .nullable() + .test( + 'is-greater-than-start', + 'Tanggal akhir tidak boleh masa lampau', + function (value) { + const { start_date } = this.parent; + if (!start_date || !value) return true; + return new Date(value) >= new Date(start_date); + } + ), + marketing_type: yup.string().nullable(), + filter_by: yup.string().nullable(), + sort_by: yup.string().nullable(), +}); + +export type DailyMarketingReportFilterValues = yup.InferType< + typeof DailyMarketingReportFilterSchema +>; diff --git a/src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx b/src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx new file mode 100644 index 00000000..ad68b8f6 --- /dev/null +++ b/src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx @@ -0,0 +1,37 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { DailyMarketingRow } from '@/types/api/report/marketing.d'; +import { ColumnDef } from '@tanstack/react-table'; + +const DailyMarketingReportSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default DailyMarketingReportSkeleton; From 4b6a8b27731123e4a8c1c07b51d26552b68e41fd Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 11:45:50 +0700 Subject: [PATCH 15/36] refactor(FE): Refactor file names for consistency in marketing report components --- .../pages/report/marketing/MarketingReportContent.tsx | 2 +- .../pages/report/marketing/export/DailyMarketingExportXLSX.tsx | 0 .../{DailyMarketingReportPDF.tsx => DailyMarketingPDF.tsx} | 0 .../{DailyMarketingReportFilter.ts => DailyMarketingFilter.ts} | 0 ...lyMarketingReportSkeleton.tsx => DailyMarketingSkeleton.tsx} | 0 .../{DailyMarketingReportContent.tsx => DailyMarketingTab.tsx} | 2 +- 6 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx rename src/components/pages/report/marketing/export/{DailyMarketingReportPDF.tsx => DailyMarketingPDF.tsx} (100%) rename src/components/pages/report/marketing/filter/{DailyMarketingReportFilter.ts => DailyMarketingFilter.ts} (100%) rename src/components/pages/report/marketing/skeleton/{DailyMarketingReportSkeleton.tsx => DailyMarketingSkeleton.tsx} (100%) rename src/components/pages/report/marketing/tab/{DailyMarketingReportContent.tsx => DailyMarketingTab.tsx} (99%) diff --git a/src/components/pages/report/marketing/MarketingReportContent.tsx b/src/components/pages/report/marketing/MarketingReportContent.tsx index 9277311f..8a814689 100644 --- a/src/components/pages/report/marketing/MarketingReportContent.tsx +++ b/src/components/pages/report/marketing/MarketingReportContent.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; -import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingReportContent'; +import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab'; import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; diff --git a/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx b/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/report/marketing/export/DailyMarketingReportPDF.tsx b/src/components/pages/report/marketing/export/DailyMarketingPDF.tsx similarity index 100% rename from src/components/pages/report/marketing/export/DailyMarketingReportPDF.tsx rename to src/components/pages/report/marketing/export/DailyMarketingPDF.tsx diff --git a/src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts b/src/components/pages/report/marketing/filter/DailyMarketingFilter.ts similarity index 100% rename from src/components/pages/report/marketing/filter/DailyMarketingReportFilter.ts rename to src/components/pages/report/marketing/filter/DailyMarketingFilter.ts diff --git a/src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx b/src/components/pages/report/marketing/skeleton/DailyMarketingSkeleton.tsx similarity index 100% rename from src/components/pages/report/marketing/skeleton/DailyMarketingReportSkeleton.tsx rename to src/components/pages/report/marketing/skeleton/DailyMarketingSkeleton.tsx diff --git a/src/components/pages/report/marketing/tab/DailyMarketingReportContent.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx similarity index 99% rename from src/components/pages/report/marketing/tab/DailyMarketingReportContent.tsx rename to src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index ca5ec12f..9f1d6d42 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingReportContent.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -16,7 +16,7 @@ import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingReportPDF'; +import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingPDF'; import { Area } from '@/types/api/master-data/area'; import { From 325fb373a8ba6a3f7ff5720a4b1e0c1c265e528c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 13:19:16 +0700 Subject: [PATCH 16/36] refactor(FE): Rename components for clarity --- src/app/report/marketing/page.tsx | 2 +- .../marketing/{MarketingReportContent.tsx => MarketingTabs.tsx} | 0 .../{DailyMarketingPDF.tsx => DailyMarketingExportPDF.tsx} | 0 src/components/pages/report/marketing/tab/DailyMarketingTab.tsx | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename src/components/pages/report/marketing/{MarketingReportContent.tsx => MarketingTabs.tsx} (100%) rename src/components/pages/report/marketing/export/{DailyMarketingPDF.tsx => DailyMarketingExportPDF.tsx} (100%) diff --git a/src/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx index 57035844..cb79f109 100644 --- a/src/app/report/marketing/page.tsx +++ b/src/app/report/marketing/page.tsx @@ -1,4 +1,4 @@ -import MarketingReportContent from '@/components/pages/report/marketing/MarketingReportContent'; +import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs'; const MarketingReportPage = () => { return ; diff --git a/src/components/pages/report/marketing/MarketingReportContent.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx similarity index 100% rename from src/components/pages/report/marketing/MarketingReportContent.tsx rename to src/components/pages/report/marketing/MarketingTabs.tsx diff --git a/src/components/pages/report/marketing/export/DailyMarketingPDF.tsx b/src/components/pages/report/marketing/export/DailyMarketingExportPDF.tsx similarity index 100% rename from src/components/pages/report/marketing/export/DailyMarketingPDF.tsx rename to src/components/pages/report/marketing/export/DailyMarketingExportPDF.tsx diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index 9f1d6d42..cedf979f 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -16,7 +16,7 @@ import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable'; import { useTableFilter } from '@/services/hooks/useTableFilter'; -import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingPDF'; +import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF'; import { Area } from '@/types/api/master-data/area'; import { From dbcf46912334db250a9baab4ca86c70f3b0f7021 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 13:44:22 +0700 Subject: [PATCH 17/36] refactor(FE): Remove DailyMarketingsTable component and refactor related files --- .../report/marketing/DailyMarketingsTable.tsx | 289 ---- .../pages/report/marketing/MarketingTabs.tsx | 2 +- .../marketing/tab/DailyMarketingTab.tsx | 1311 +++++++++++------ 3 files changed, 887 insertions(+), 715 deletions(-) delete mode 100644 src/components/pages/report/marketing/DailyMarketingsTable.tsx diff --git a/src/components/pages/report/marketing/DailyMarketingsTable.tsx b/src/components/pages/report/marketing/DailyMarketingsTable.tsx deleted file mode 100644 index 4904ef16..00000000 --- a/src/components/pages/report/marketing/DailyMarketingsTable.tsx +++ /dev/null @@ -1,289 +0,0 @@ -'use client'; - -import { ChangeEventHandler, useEffect, useState } from 'react'; -import useSWR from 'swr'; -import { ColumnDef, SortingState } from '@tanstack/react-table'; - -import { Icon } from '@iconify/react'; -import Table from '@/components/Table'; -import DebouncedTextInput from '@/components/input/DebouncedTextInput'; -import Card from '@/components/Card'; -import Collapse from '@/components/Collapse'; - -import { - cn, - formatCurrency, - formatDate, - formatNumber, - formatVechicleNumber, -} from '@/lib/helper'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { DailyMarketingRow } from '@/types/api/report/marketing'; -import { MarketingReportApi } from '@/services/api/report/marketing-report'; - -interface DailyMarketingsTableProps { - dailyMarketingsReportUrl: string; - onSetPage: (page: number) => void; - pageSize: number; - onSetPageSize: (pageSize: number) => void; - searchValue: string; - onSearchChange: ChangeEventHandler; - onFilterByChange: (filterBy: string) => void; - onSortByChange: (sort: 'asc' | 'desc' | '') => void; -} - -const DailyMarketingsTable = ({ - dailyMarketingsReportUrl, - onSetPage, - pageSize, - onSetPageSize, - searchValue, - onSearchChange, - onFilterByChange, - onSortByChange, -}: DailyMarketingsTableProps) => { - const { data: dailyMarketings, isLoading: isLoadingDailyMarketings } = useSWR( - dailyMarketingsReportUrl, - MarketingReportApi.getAllDailyMarketingFetcher, - { - keepPreviousData: true, - } - ); - - const [open, setOpen] = useState(true); - - const [sorting, setSorting] = useState([]); - - const dailyMarketingColumns: ColumnDef[] = [ - { - header: 'No', - cell: (props) => props.row.index + 1, - }, - { - accessorKey: 'so_date', - header: 'Tanggal Jual', - cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'), - footer: 'Total', - }, - { - accessorKey: 'realization_date', - header: 'Tanggal Realisasi', - cell: (props) => - formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'), - }, - { - accessorKey: 'aging_days', - header: 'Aging', - cell: (props) => `${props.row.original.aging_days} hari`, - }, - { - accessorKey: 'warehouse', - header: 'Gudang', - cell: ({ row }) => row.original.warehouse.name, - }, - { - accessorKey: 'customer', - header: 'Pelanggan', - cell: ({ row }) => row.original.customer.name, - }, - { - accessorKey: 'do_number', - header: 'No. DO', - enableSorting: false, - }, - { - accessorKey: 'sales_person', - header: 'Sales/Marketing', - cell: (props) => props.row.original.sales.name, - }, - { - accessorKey: 'vehicle_number', - header: 'No. Polisi', - cell: (props) => ( - - {formatVechicleNumber(props.row.original.vehicle_number)} - - ), - }, - { - accessorKey: 'marketing_type', - header: 'Marketing Type', - enableSorting: false, - }, - { - accessorKey: 'product', - header: 'Produk', - cell: ({ row }) => row.original.product.name, - }, - { - accessorKey: 'qty', - header: 'Kuantitas', - cell: (props) => formatNumber(props.row.original.qty), - footer: () => { - const totalQty = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.total_qty - : 0; - - return totalQty ? formatNumber(totalQty) : '-'; - }, - }, - { - accessorKey: 'average_weight', - header: 'Bobot Rata-Rata (Kg)', - cell: (props) => formatNumber(props.row.original.average_weight_kg), - footer: () => { - const totalAverageWeightKg = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.average_weight_kg - : 0; - - return totalAverageWeightKg ? formatNumber(totalAverageWeightKg) : '-'; - }, - }, - { - accessorKey: 'total_weight', - header: 'Bobot Total (Kg)', - cell: (props) => formatNumber(props.row.original.total_weight_kg), - footer: () => { - const totalWeightKg = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.total_weight_kg - : 0; - - return totalWeightKg ? formatNumber(totalWeightKg) : '-'; - }, - }, - { - accessorKey: 'sales_price', - header: 'Harga Jual (Rp)', - cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), - footer: () => { - const totalSalesPrice = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.average_sales_price - : 0; - - return totalSalesPrice ? formatNumber(totalSalesPrice) : '-'; - }, - }, - { - accessorKey: 'hpp_price', - header: 'HPP (Rp)', - cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), - footer: () => { - const totalHppPricePerKg = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.total_hpp_price_per_kg - : 0; - - return totalHppPricePerKg ? formatCurrency(totalHppPricePerKg) : '-'; - }, - }, - { - accessorKey: 'sales_amount', - header: 'Total (Rp)', - cell: (props) => formatCurrency(props.row.original.sales_amount), - footer: () => { - const totalSalesAmount = isResponseSuccess(dailyMarketings) - ? dailyMarketings?.total?.total_sales_amount - : 0; - - return totalSalesAmount ? formatCurrency(totalSalesAmount) : '-'; - }, - }, - ]; - - useEffect(() => { - if (sorting.length === 1) { - onFilterByChange(sorting[0].id); - onSortByChange(sorting[0].desc ? 'desc' : 'asc'); - } else { - onFilterByChange(''); - onSortByChange(''); - } - }, [sorting]); - - useEffect(() => { - if (!open) { - setOpen( - isResponseSuccess(dailyMarketings) - ? dailyMarketings.data.length > 0 - : false - ); - } - }, [dailyMarketings, isResponseSuccess]); - - return ( - - -
Penjualan Harian
- - - - } - className='w-full!' - titleClassName='w-full p-0!' - > -
-
-
- -
-
- - - data={ - isResponseSuccess(dailyMarketings) ? dailyMarketings?.data : [] - } - columns={dailyMarketingColumns} - pageSize={pageSize} - onPageSizeChange={onSetPageSize} - rowOptions={[10, 20, 50, 100]} - page={ - isResponseSuccess(dailyMarketings) - ? dailyMarketings?.meta?.page - : 0 - } - totalItems={ - isResponseSuccess(dailyMarketings) - ? dailyMarketings?.meta?.total_results - : 0 - } - onPageChange={onSetPage} - isLoading={isLoadingDailyMarketings} - sorting={sorting} - setSorting={setSorting} - renderFooter={true} - className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(dailyMarketings) && - dailyMarketings?.data?.length === 0, - }), - }} - /> -
-
-
- ); -}; - -export default DailyMarketingsTable; diff --git a/src/components/pages/report/marketing/MarketingTabs.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx index 8a814689..de449f9c 100644 --- a/src/components/pages/report/marketing/MarketingTabs.tsx +++ b/src/components/pages/report/marketing/MarketingTabs.tsx @@ -14,7 +14,7 @@ const MarketingReportContent = () => { { id: '1', label: 'Penjualan Harian', - content: , + content: , }, { id: '2', diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index cedf979f..5fcc630f 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -1,472 +1,933 @@ -'use client'; - -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { useState, useMemo, useCallback } from 'react'; +import useSWR from 'swr'; +import { useSelect } from '@/components/input/SelectInput'; +import DateInput from '@/components/input/DateInput'; +import DebouncedTextInput from '@/components/input/DebouncedTextInput'; +import { AreaApi } from '@/services/api/master-data'; +import { LocationApi } from '@/services/api/master-data'; +import { WarehouseApi } from '@/services/api/master-data'; +import { CustomerApi } from '@/services/api/master-data'; +import { MarketingReportApi } from '@/services/api/report/marketing-report'; +import Table from '@/components/Table'; +import { ColumnDef } from '@tanstack/react-table'; +import { + formatCurrency, + formatNumber, + formatDate, + formatVechicleNumber, +} from '@/lib/helper'; +import { + DailyMarketingRow, + DailyMarketingReportResponse, +} from '@/types/api/report/marketing'; +import { isResponseSuccess } from '@/lib/api-helper'; +import Button from '@/components/Button'; +import Dropdown from '@/components/Dropdown'; +import MenuItem from '@/components/menu/MenuItem'; +import Menu from '@/components/menu/Menu'; +import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF'; import { pdf } from '@react-pdf/renderer'; import toast from 'react-hot-toast'; - import { Icon } from '@iconify/react'; -import Button from '@/components/Button'; -import Dropdown from '@/components/dropdown/Dropdown'; -import DateInput from '@/components/input/DateInput'; -import SelectInput, { - OptionType, - useSelect, -} from '@/components/input/SelectInput'; -import Menu from '@/components/menu/Menu'; -import MenuItem from '@/components/menu/MenuItem'; -import DailyMarketingsTable from '@/components/pages/report/marketing/DailyMarketingsTable'; -import { useTableFilter } from '@/services/hooks/useTableFilter'; -import DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF'; - -import { Area } from '@/types/api/master-data/area'; +import { useFormik } from 'formik'; import { - AreaApi, - CustomerApi, - LocationApi, - WarehouseApi, -} from '@/services/api/master-data'; -import { Warehouse } from '@/types/api/master-data/warehouse'; -import { Customer } from '@/types/api/master-data/customer'; -import { MarketingReportApi } from '@/services/api/report/marketing-report'; + DailyMarketingReportFilterSchema, + DailyMarketingReportFilterType, +} from '@/components/pages/report/marketing/filter/DailyMarketingFilter'; +import SelectInput from '@/components/input/SelectInput'; +import Modal, { useModal } from '@/components/Modal'; +import { cn } from '@/lib/helper'; +import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.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, } from '@/config/constant'; -import { httpClient } from '@/services/http/client'; -import { BaseApiResponse } from '@/types/api/api-general'; -import { - DailyMarketingReport, - DailyMarketingReportResponse, -} from '@/types/api/report/marketing'; -import { isResponseError } from '@/lib/api-helper'; +import Badge from '@/components/Badge'; -const DailyMarketingReportContent = () => { - const { - state: tableFilterState, - updateFilter, - setPage, - setPageSize, - toQueryString: getTableFilterQueryString, - reset: resetFilter, - } = useTableFilter({ - initial: { - search: '', - area_id: '', - location_id: '', - warehouse_id: '', - customer_id: '', - start_date: '', - end_date: '', - marketing_type: '', - filter_by: '', - sort_by: '', +interface DailyMarketingTabProps { + tabId: string; +} + +interface FilterParams { + area_id?: string; + location_id?: string; + warehouse_id?: string; + customer_id?: string; + start_date?: string; + end_date?: string; + filter_by?: string; + marketing_type?: string; + sort_by?: string; +} + +const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { + // ===== STATE MANAGEMENT ===== + const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); + const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); + const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; + + // ===== SUBMISSION STATE ===== + const [isSubmitted, setIsSubmitted] = useState(false); + + // ===== SEARCH STATE ===== + const [searchValue, setSearchValue] = useState(''); + + // ===== FILTER STATE ===== + const [filterParams, setFilterParams] = useState({}); + + const filterModal = useModal(); + + // ===== OPTIONS ===== + const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( + AreaApi.basePath, + 'id', + 'name', + 'search' + ); + + const { options: locationOptions, isLoadingOptions: isLoadingLocations } = + useSelect(LocationApi.basePath, 'id', 'name', 'search'); + + const { options: warehouseOptions, isLoadingOptions: isLoadingWarehouses } = + useSelect(WarehouseApi.basePath, 'id', 'name', 'search'); + + const { options: customerOptions, isLoadingOptions: isLoadingCustomers } = + useSelect(CustomerApi.basePath, 'id', 'name', 'search'); + + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + search: null, + area_id: null, + location_id: null, + warehouse_id: null, + customer_id: null, + start_date: null, + end_date: null, + filter_by: null, + marketing_type: null, + sort_by: null, }, - paramMap: { - page: 'page', - pageSize: 'limit', - area_id: 'area_id', - location_id: 'location_id', - warehouse_id: 'warehouse_id', - customer_id: 'customer_id', - start_date: 'start_date', - end_date: 'end_date', - marketing_type: 'marketing_type', - filter_by: 'filter_by', - sort_by: 'sort_by', + validationSchema: DailyMarketingReportFilterSchema, + onSubmit: (values, { setSubmitting }) => { + setFilterParams({ + area_id: values.area_id || undefined, + location_id: values.location_id || undefined, + warehouse_id: values.warehouse_id || undefined, + customer_id: values.customer_id || undefined, + start_date: values.start_date || undefined, + end_date: values.end_date || undefined, + filter_by: values.filter_by || undefined, + marketing_type: values.marketing_type || undefined, + sort_by: values.sort_by || undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setSubmitting(false); + }, + onReset: () => { + setFilterParams({}); + setIsSubmitted(false); }, }); - const dailyMarketingsReportUrl = `${MarketingReportApi.basePath}${getTableFilterQueryString()}`; - - const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = - useState(false); - const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false); - - const [selectedArea, setSelectedArea] = useState(null); - const { - setInputValue: setAreaInputValue, - options: areaOptions, - isLoadingOptions: isLoadingAreaOptions, - loadMore: loadMoreAreas, - } = useSelect(AreaApi.basePath, 'id', 'name'); - - const areaChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedArea(val as OptionType); - updateFilter('area_id', val ? ((val as OptionType).value as string) : ''); - }; - - const [selectedLocation, setSelectedLocation] = useState( - null + // ===== SEARCH CHANGE HANDLER ===== + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearchValue(e.target.value); + }, + [] ); - const { - setInputValue: setLocationInputValue, - options: locationOptions, - isLoadingOptions: isLoadingLocationOptions, - loadMore: loadMoreLocations, - } = useSelect(LocationApi.basePath, 'id', 'name'); - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedLocation(val as OptionType); - updateFilter( - 'location_id', - val ? ((val as OptionType).value as string) : '' + // ===== DERIVED VALUES ===== + const areaValue = 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 [selectedWarehouse, setSelectedWarehouse] = useState( - null + const locationValue = 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 warehouseValue = useMemo(() => { + if (!formik.values.warehouse_id) return null; + return ( + warehouseOptions.find( + (opt) => String(opt.value) === formik.values.warehouse_id + ) || null + ); + }, [formik.values.warehouse_id, warehouseOptions]); + + const customerValue = useMemo(() => { + if (!formik.values.customer_id) return null; + return ( + customerOptions.find( + (opt) => String(opt.value) === formik.values.customer_id + ) || null + ); + }, [formik.values.customer_id, customerOptions]); + + const filterByValue = useMemo(() => { + if (!formik.values.filter_by) return null; + return ( + MARKETING_DATE_FILTER_TYPE_OPTIONS.find( + (opt) => opt.value === formik.values.filter_by + ) || null + ); + }, [formik.values.filter_by]); + + const marketingTypeValue = useMemo(() => { + if (!formik.values.marketing_type) return null; + return ( + MARKETING_TYPE_OPTIONS.find( + (opt) => opt.value === formik.values.marketing_type + ) || null + ); + }, [formik.values.marketing_type]); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + if (filterParams.area_id) { + count += 1; + } + + if (filterParams.location_id) { + count += 1; + } + + if (filterParams.warehouse_id) { + count += 1; + } + + if (filterParams.customer_id) { + count += 1; + } + + if (filterParams.start_date || filterParams.end_date) { + count += 1; + } + + if (filterParams.filter_by) { + count += 1; + } + + if (filterParams.marketing_type) { + count += 1; + } + + if (filterParams.sort_by) { + count += 1; + } + + return count; + }, [filterParams]); + + const hasFilters = activeFiltersCount > 0; + + // ===== DATA FETCHING ===== + const { data: dailyMarketings, isLoading } = useSWR( + isSubmitted + ? () => { + const params = new URLSearchParams(); + + 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); + + return ['daily-marketing-report', params.toString()]; + } + : null, + ([, params]) => + MarketingReportApi.getAllDailyMarketingFetcher( + `${MarketingReportApi.basePath}?${params}` + ) ); - const { - setInputValue: setWarehouseInputValue, - options: warehouseOptions, - isLoadingOptions: isLoadingWarehouseOptions, - loadMore: loadMoreWarehouses, - } = useSelect(WarehouseApi.basePath, 'id', 'name'); - const warehouseChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedWarehouse(val as OptionType); - updateFilter( - 'warehouse_id', - val ? ((val as OptionType).value as string) : '' - ); - }; - - const [selectedCustomer, setSelectedCustomer] = useState( - null + const data: DailyMarketingRow[] = useMemo( + () => + isResponseSuccess(dailyMarketings) + ? (dailyMarketings?.data as DailyMarketingRow[]) || [] + : [], + [dailyMarketings] ); - const { - setInputValue: setCustomerInputValue, - options: customerOptions, - isLoadingOptions: isLoadingCustomerOptions, - loadMore: loadMoreCustomers, - } = useSelect(CustomerApi.basePath, 'id', 'name'); - const customerChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedCustomer(val as OptionType); - updateFilter( - 'customer_id', - val ? ((val as OptionType).value as string) : '' - ); - }; + const summaryTotal = useMemo( + () => + isResponseSuccess(dailyMarketings) && dailyMarketings?.total + ? dailyMarketings.total + : undefined, + [dailyMarketings] + ); - const startDateChangeHandler = (e: React.ChangeEvent) => { - updateFilter('start_date', e.target.value ? e.target.value : ''); - }; - - const endDateChangeHandler = (e: React.ChangeEvent) => { - updateFilter('end_date', e.target.value ? e.target.value : ''); - }; - - const [selectedMarketingDateFilterType, setSelectedMarketingDateFilterType] = - useState(null); - const marketingDateFilterTypeChangeHandler = ( - val: OptionType | OptionType[] | null - ) => { - setSelectedMarketingDateFilterType(val as OptionType); - updateFilter('filter_by', val ? ((val as OptionType).value as string) : ''); - }; - - const [selectedMarketingType, setSelectedMarketingType] = - useState(null); - const marketingTypeChangeHandler = ( - val: OptionType | OptionType[] | null - ) => { - setSelectedMarketingType(val as OptionType); - updateFilter( - 'marketing_type', - val ? ((val as OptionType).value as string) : '' - ); - }; - - const searchChangeHandler: ChangeEventHandler = (e) => { - updateFilter('search', e.target.value); - }; - - const filterByChangeHandler = (filterBy: string) => { - updateFilter('filter_by', filterBy); - }; - - const sortByChangeHandler = (sort: 'asc' | 'desc' | '') => { - updateFilter('sort_by', sort); - }; - - const exportToExcelHandler = async () => { - setIsLoadingExportingToExcel(true); - - await MarketingReportApi.exportDailyMarketingToExcel( - getTableFilterQueryString() - ); - - setIsLoadingExportingToExcel(false); - }; - - const exportToPdfHandler = async () => { - setIsLoadingExportingToPdf(true); - - const params = new URLSearchParams(getTableFilterQueryString()); + // ===== EXPORT DATA FETCHER ===== + const dailyMarketingsExport = useCallback(async (): Promise< + DailyMarketingRow[] | null + > => { + const params = new URLSearchParams(); + 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); params.set('limit', '9999999'); const queryString = `?${params.toString()}`; try { - const dailyMarketingsReport = - await httpClient( - `${MarketingReportApi.basePath}${queryString}` - ); + const response = await httpClient( + `${MarketingReportApi.basePath}${queryString}` + ); - if (isResponseError(dailyMarketingsReport)) { - toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + if (isResponseError(response)) { + return null; + } + + return response.data || []; + } catch { + return null; + } + }, [filterParams, searchValue]); + + // ===== EXPORT HANDLERS ===== + const handleExportExcel = useCallback(async () => { + setIsExcelExportLoading(true); + try { + const queryString = new URLSearchParams(); + + if (searchValue) queryString.set('search', searchValue); + if (filterParams.area_id) + queryString.set('area_id', filterParams.area_id); + if (filterParams.location_id) + queryString.set('location_id', filterParams.location_id); + if (filterParams.warehouse_id) + queryString.set('warehouse_id', filterParams.warehouse_id); + if (filterParams.customer_id) + queryString.set('customer_id', filterParams.customer_id); + if (filterParams.start_date) + queryString.set('start_date', filterParams.start_date); + if (filterParams.end_date) + queryString.set('end_date', filterParams.end_date); + if (filterParams.filter_by) + queryString.set('filter_by', filterParams.filter_by); + if (filterParams.marketing_type) + queryString.set('marketing_type', filterParams.marketing_type); + if (filterParams.sort_by) + queryString.set('sort_by', filterParams.sort_by); + + await MarketingReportApi.exportDailyMarketingToExcel( + `?${queryString.toString()}` + ); + + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } + }, [filterParams, searchValue]); + + const handleExportPDF = useCallback(async () => { + setIsPdfExportLoading(true); + try { + const allDataForExport = await dailyMarketingsExport(); + + if (!allDataForExport || allDataForExport.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); return; } - const openPdf = async () => { - const dailyMarketingReportPdfBlob = await pdf( - - ).toBlob(); + const dailyMarketingReportPdfBlob = await pdf( + + ).toBlob(); - const dailyMarketingReportPdfUrl = URL.createObjectURL( - dailyMarketingReportPdfBlob - ); - window.open(dailyMarketingReportPdfUrl, '_blank'); - }; + const dailyMarketingReportPdfUrl = URL.createObjectURL( + dailyMarketingReportPdfBlob + ); + window.open(dailyMarketingReportPdfUrl, '_blank'); - const downloadPdf = async () => { - const blob = await pdf( - - ).toBlob(); - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.href = url; - link.download = 'laporan-penjualan-harian.pdf'; - link.click(); - - URL.revokeObjectURL(url); - }; - - await openPdf(); - } catch (error) { - toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); + toast.success('PDF berhasil dibuat.'); + } catch { + toast.error('Gagal membuat PDF. Silakan coba lagi.'); + } finally { + setIsPdfExportLoading(false); } + }, [dailyMarketingsExport, summaryTotal]); - setIsLoadingExportingToPdf(false); + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useMarketingTabStore((state) => state.setTabActions); + const clearTabActions = useMarketingTabStore( + (state) => state.clearTabActions + ); + + useEffectHook(() => { + setTabActions( + tabId, +
+ + } + className={{ + wrapper: 'w-full min-w-48 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: 'placeholder:font-semibold placeholder:text-base-content/50', + }} + /> + + + + +
+ + + Export + +
+ + +
+ + } + > + + + +
+ ); + }, [ + tabId, + searchValue, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + filterModal.open, + setTabActions, + ]); + + useEffectHook(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); + + const getTableColumns = (): ColumnDef[] => { + const tableColumns: ColumnDef[] = [ + { + id: 'no', + header: 'No', + cell: (props) => props.row.index + 1, + footer: () =>
TOTAL
, + }, + { + id: 'so_date', + header: 'Tanggal Jual', + accessorKey: 'so_date', + cell: (props) => formatDate(props.row.original.so_date, 'DD-MMM-YYYY'), + footer: () =>
ALL
, + }, + { + id: 'realization_date', + header: 'Tanggal Realisasi', + accessorKey: 'realization_date', + cell: (props) => + formatDate(props.row.original.realization_date, 'DD-MMM-YYYY'), + footer: () =>
-
, + }, + { + id: 'aging_days', + header: 'Aging', + accessorKey: 'aging_days', + cell: (props) => `${props.row.original.aging_days} hari`, + footer: () =>
-
, + }, + { + id: 'warehouse', + header: 'Gudang', + accessorKey: 'warehouse', + cell: ({ row }) => row.original.warehouse.name, + footer: () =>
-
, + }, + { + id: 'customer', + header: 'Pelanggan', + accessorKey: 'customer', + cell: ({ row }) => row.original.customer.name, + footer: () =>
-
, + }, + { + id: 'do_number', + header: 'No. DO', + accessorKey: 'do_number', + footer: () =>
-
, + }, + { + id: 'sales_person', + header: 'Sales/Marketing', + accessorKey: 'sales', + cell: (props) => props.row.original.sales.name, + footer: () =>
-
, + }, + { + id: 'vehicle_number', + header: 'No. Polisi', + accessorKey: 'vehicle_number', + cell: (props) => ( + + {formatVechicleNumber(props.row.original.vehicle_number)} + + ), + footer: () =>
-
, + }, + { + id: 'marketing_type', + header: 'Marketing Type', + accessorKey: 'marketing_type', + footer: () =>
-
, + }, + { + id: 'product', + header: 'Produk', + accessorKey: 'product', + cell: ({ row }) => row.original.product.name, + footer: () =>
-
, + }, + { + id: 'qty', + header: 'Kuantitas', + accessorKey: 'qty', + cell: (props) => formatNumber(props.row.original.qty), + footer: () => ( +
+ {summaryTotal?.total_qty + ? formatNumber(summaryTotal.total_qty) + : '-'} +
+ ), + }, + { + id: 'average_weight', + header: 'Bobot Rata-Rata (Kg)', + accessorKey: 'average_weight_kg', + cell: (props) => formatNumber(props.row.original.average_weight_kg), + footer: () => ( +
+ {summaryTotal?.average_weight_kg + ? formatNumber(summaryTotal.average_weight_kg) + : '-'} +
+ ), + }, + { + id: 'total_weight', + header: 'Bobot Total (Kg)', + accessorKey: 'total_weight_kg', + cell: (props) => formatNumber(props.row.original.total_weight_kg), + footer: () => ( +
+ {summaryTotal?.total_weight_kg + ? formatNumber(summaryTotal.total_weight_kg) + : '-'} +
+ ), + }, + { + id: 'sales_price', + header: 'Harga Jual (Rp)', + accessorKey: 'sales_price_per_kg', + cell: (props) => formatCurrency(props.row.original.sales_price_per_kg), + footer: () => ( +
+ {summaryTotal?.average_sales_price + ? formatNumber(summaryTotal.average_sales_price) + : '-'} +
+ ), + }, + { + id: 'hpp_price', + header: 'HPP (Rp)', + accessorKey: 'hpp_price_per_kg', + cell: (props) => formatCurrency(props.row.original.hpp_price_per_kg), + footer: () => ( +
+ {summaryTotal?.total_hpp_price_per_kg + ? formatCurrency(summaryTotal.total_hpp_price_per_kg) + : '-'} +
+ ), + }, + { + id: 'sales_amount', + header: 'Total (Rp)', + accessorKey: 'sales_amount', + cell: (props) => formatCurrency(props.row.original.sales_amount), + footer: () => ( +
+ {summaryTotal?.total_sales_amount + ? formatCurrency(summaryTotal.total_sales_amount) + : '-'} +
+ ), + }, + ]; + return tableColumns; }; - const handleReset = () => { - setSelectedArea(null); - setSelectedLocation(null); - setSelectedWarehouse(null); - setSelectedCustomer(null); - setSelectedMarketingType(null); - resetFilter(); - }; - - useEffect(() => { - if ( - tableFilterState.filter_by === 'realization_date' || - tableFilterState.filter_by === 'so_date' - ) { - setSelectedMarketingDateFilterType({ - label: - tableFilterState.filter_by === 'realization_date' - ? 'Tanggal Realisasi' - : 'Tanggal SO', - value: tableFilterState.filter_by, - }); - } else { - setSelectedMarketingDateFilterType(null); - } - }, [tableFilterState.filter_by]); - return ( -
-
-

Penjualan Harian

+ <> +
+ {!isSubmitted ? ( + + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> + ) : isLoading ? ( + + } + title='Memuat Data Penjualan Harian' + subtitle='Silakan tunggu sebentar...' + /> + ) : data.length === 0 ? ( + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + ) : ( +
0} + className={{ + containerClassName: cn('p-3', { + 'w-full mb-20': data.length === 0, + }), + headerColumnClassName: 'text-nowrap', + }} + /> + )} - {/* Filters */} -
-
- - - - - - - - - - - -
- -
- - - - -
- - - - - - Export{' '} - - - } - > - - - - - + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
-
+
+
+ {/* Area Filter */} + { + formik.setFieldValue( + 'area_id', + val && !Array.isArray(val) ? String(val.value) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> - -
+ {/* Location Filter */} + { + formik.setFieldValue( + 'location_id', + val && !Array.isArray(val) ? String(val.value) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Warehouse Filter */} + { + formik.setFieldValue( + 'warehouse_id', + val && !Array.isArray(val) ? String(val.value) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Customer Filter */} + { + formik.setFieldValue( + 'customer_id', + val && !Array.isArray(val) ? String(val.value) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Date Range Filter */} +
+ +
+ { + formik.setFieldValue('start_date', e.target.value || null); + }} + className={{ wrapper: 'w-full' }} + isError={ + !!formik.errors.start_date && formik.touched.start_date + } + /> + {formik.errors.start_date && formik.touched.start_date && ( +
+ {formik.errors.start_date} +
+ )} + { + formik.setFieldValue('end_date', e.target.value || null); + }} + className={{ wrapper: 'w-full' }} + isError={!!formik.errors.end_date && formik.touched.end_date} + /> + {formik.errors.end_date && formik.touched.end_date && ( +
+ {formik.errors.end_date} +
+ )} +
+
+ + {/* Filter By Date Type */} + { + formik.setFieldValue( + 'filter_by', + val && !Array.isArray(val) ? (val.value as string) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Marketing Type Filter */} + { + formik.setFieldValue( + 'marketing_type', + val && !Array.isArray(val) ? (val.value as string) : null + ); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ + + ); }; -export default DailyMarketingReportContent; +export default DailyMarketingTab; From 510573e66f16c1f2dd3459e5343755cbeecb962b Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 13:55:31 +0700 Subject: [PATCH 18/36] refactor(FE): Add Excel export functionality for daily marketing report --- .../export/DailyMarketingExportXLSX.tsx | 118 ++++++++++++++++++ .../marketing/tab/DailyMarketingTab.tsx | 49 ++++---- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx b/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx index e69de29b..8c368fbf 100644 --- a/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx +++ b/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx @@ -0,0 +1,118 @@ +'use client'; + +import ExcelJS from 'exceljs'; +import { + formatCurrency, + formatNumber, + formatDate, + formatVechicleNumber, +} from '@/lib/helper'; +import { DailyMarketingRow, SalesSummary } from '@/types/api/report/marketing'; + +interface DailyMarketingExportExcelParams { + data: DailyMarketingRow[]; + summaryTotal?: SalesSummary; + period?: string; +} + +export const generateDailyMarketingExcel = async ( + params: DailyMarketingExportExcelParams +): Promise => { + if (!params.data || params.data.length === 0) { + return; + } + + const workbook = new ExcelJS.Workbook(); + + // ===== DAILY MARKETING WORKSHEET ===== + const columns = [ + { header: 'No', key: 'no', width: 5 }, + { header: 'Tanggal Jual', key: 'soDate', width: 15 }, + { header: 'Tanggal Realisasi', key: 'realizationDate', width: 18 }, + { header: 'Aging', key: 'aging', width: 10 }, + { header: 'Gudang', key: 'warehouse', width: 25 }, + { header: 'Pelanggan', key: 'customer', width: 25 }, + { header: 'No. DO', key: 'doNumber', width: 15 }, + { header: 'Sales/Marketing', key: 'sales', width: 20 }, + { header: 'No. Polisi', key: 'vehicleNumber', width: 15 }, + { header: 'Marketing Type', key: 'marketingType', width: 15 }, + { header: 'Produk', key: 'product', width: 20 }, + { header: 'Kuantitas', key: 'qty', width: 12 }, + { header: 'Bobot Rata-Rata (Kg)', key: 'averageWeight', width: 20 }, + { header: 'Bobot Total (Kg)', key: 'totalWeight', width: 18 }, + { header: 'Harga Jual (Rp)', key: 'salesPrice', width: 18 }, + { header: 'HPP (Rp)', key: 'hppPrice', width: 15 }, + { header: 'Total (Rp)', key: 'salesAmount', width: 20 }, + ]; + + const worksheet = workbook.addWorksheet('Laporan Marketing Harian'); + worksheet.columns = columns; + + // Add data rows + params.data.forEach((item: DailyMarketingRow, index: number) => { + worksheet.addRow({ + no: index + 1, + soDate: formatDate(item.so_date, 'DD-MMM-YYYY'), + realizationDate: formatDate(item.realization_date, 'DD-MMM-YYYY'), + aging: `${item.aging_days} hari`, + warehouse: item.warehouse?.name || '', + customer: item.customer?.name || '', + doNumber: item.do_number || '', + sales: item.sales?.name || '', + vehicleNumber: formatVechicleNumber(item.vehicle_number), + marketingType: item.marketing_type || '', + product: item.product?.name || '', + qty: formatNumber(item.qty || 0), + averageWeight: formatNumber(item.average_weight_kg || 0), + totalWeight: formatNumber(item.total_weight_kg || 0), + salesPrice: formatCurrency(item.sales_price_per_kg || 0), + hppPrice: formatCurrency(item.hpp_price_per_kg || 0), + salesAmount: formatCurrency(item.sales_amount || 0), + }); + }); + + // Add TOTAL row if summary data is available + if (params.summaryTotal) { + worksheet.addRow({ + no: 'TOTAL', + soDate: 'ALL', + realizationDate: '-', + aging: '-', + warehouse: '-', + customer: '-', + doNumber: '-', + sales: '-', + vehicleNumber: '-', + marketingType: '-', + product: '-', + qty: formatNumber(params.summaryTotal.total_qty || 0), + averageWeight: formatNumber(params.summaryTotal.average_weight_kg || 0), + totalWeight: formatNumber(params.summaryTotal.total_weight_kg || 0), + salesPrice: formatNumber(params.summaryTotal.average_sales_price || 0), + hppPrice: formatCurrency(params.summaryTotal.total_hpp_price_per_kg || 0), + salesAmount: formatCurrency(params.summaryTotal.total_sales_amount || 0), + }); + } + + worksheet.columns.forEach((column) => { + if (column.width && column.width < 10) { + column.width = 10; + } + }); + + const currentDate = new Date().toISOString().split('T')[0]; + const filename = params.period + ? `laporan-marketing-harian-${params.period}.xlsx` + : `laporan-marketing-harian-${currentDate}.xlsx`; + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +}; diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index 5fcc630f..a687fdf5 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -15,6 +15,7 @@ import { formatNumber, formatDate, formatVechicleNumber, + formatTitleCase, } from '@/lib/helper'; import { DailyMarketingRow, @@ -23,9 +24,8 @@ import { import { isResponseSuccess } from '@/lib/api-helper'; import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; -import MenuItem from '@/components/menu/MenuItem'; -import Menu from '@/components/menu/Menu'; 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'; @@ -336,31 +336,23 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { const handleExportExcel = useCallback(async () => { setIsExcelExportLoading(true); try { - const queryString = new URLSearchParams(); + const allDataForExport = await dailyMarketingsExport(); - if (searchValue) queryString.set('search', searchValue); - if (filterParams.area_id) - queryString.set('area_id', filterParams.area_id); - if (filterParams.location_id) - queryString.set('location_id', filterParams.location_id); - if (filterParams.warehouse_id) - queryString.set('warehouse_id', filterParams.warehouse_id); - if (filterParams.customer_id) - queryString.set('customer_id', filterParams.customer_id); - if (filterParams.start_date) - queryString.set('start_date', filterParams.start_date); - if (filterParams.end_date) - queryString.set('end_date', filterParams.end_date); - if (filterParams.filter_by) - queryString.set('filter_by', filterParams.filter_by); - if (filterParams.marketing_type) - queryString.set('marketing_type', filterParams.marketing_type); - if (filterParams.sort_by) - queryString.set('sort_by', filterParams.sort_by); + if (!allDataForExport || allDataForExport.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } - await MarketingReportApi.exportDailyMarketingToExcel( - `?${queryString.toString()}` - ); + 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, + }); toast.success('Excel berhasil dibuat dan diunduh.'); } catch { @@ -368,7 +360,7 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { } finally { setIsExcelExportLoading(false); } - }, [filterParams, searchValue]); + }, [filterParams, dailyMarketingsExport, summaryTotal]); const handleExportPDF = useCallback(async () => { setIsPdfExportLoading(true); @@ -588,6 +580,11 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { id: 'marketing_type', header: 'Marketing Type', accessorKey: 'marketing_type', + cell: (props) => ( + + {formatTitleCase(props.row.original.marketing_type || '-')} + + ), footer: () =>
-
, }, { From b154b478bcb477ee8b77e4ec9ea86115d7dd2798 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 14:15:56 +0700 Subject: [PATCH 19/36] refactor(FE): Refactor production result components structure --- src/app/report/production-result/page.tsx | 2 +- .../ProductionResultExportPDF.tsx} | 0 .../production-result/export/ProductionResultExportXLSX.tsx | 0 .../report/production-result/filter/ProductionResultFilter.ts | 0 .../production-result/skeleton/ProductionResultSkeleton.tsx | 0 .../ProductionResultTab.tsx} | 2 +- 6 files changed, 2 insertions(+), 2 deletions(-) rename src/components/pages/report/production-result/{ProductionResultReportPDF.tsx => export/ProductionResultExportPDF.tsx} (100%) create mode 100644 src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx create mode 100644 src/components/pages/report/production-result/filter/ProductionResultFilter.ts create mode 100644 src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx rename src/components/pages/report/production-result/{ProductionResultContent.tsx => tab/ProductionResultTab.tsx} (99%) diff --git a/src/app/report/production-result/page.tsx b/src/app/report/production-result/page.tsx index cdac598c..fb8f2a0c 100644 --- a/src/app/report/production-result/page.tsx +++ b/src/app/report/production-result/page.tsx @@ -1,4 +1,4 @@ -import ProductionResultContent from '@/components/pages/report/production-result/ProductionResultContent'; +import ProductionResultContent from '@/components/pages/report/production-result/tab/ProductionResultTab'; const ProductionResultReportPage = () => { return ( diff --git a/src/components/pages/report/production-result/ProductionResultReportPDF.tsx b/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx similarity index 100% rename from src/components/pages/report/production-result/ProductionResultReportPDF.tsx rename to src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx diff --git a/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx b/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/pages/report/production-result/ProductionResultContent.tsx b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx similarity index 99% rename from src/components/pages/report/production-result/ProductionResultContent.tsx rename to src/components/pages/report/production-result/tab/ProductionResultTab.tsx index d79d4c94..84b21c41 100644 --- a/src/components/pages/report/production-result/ProductionResultContent.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx @@ -31,7 +31,7 @@ import { ProductionResultReportApi } from '@/services/api/report/production-resu import { BaseApiResponse } from '@/types/api/api-general'; import { httpClient } from '@/services/http/client'; import { ProductionResult } from '@/types/api/report/production-result'; -import ProductionResultReportPDF from './ProductionResultReportPDF'; +import ProductionResultReportPDF from '../export/ProductionResultExportPDF'; import { pdf } from '@react-pdf/renderer'; const ProductionResultContent = () => { From dc4e945a35946accb5e3115640870962287ec91d Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 14:50:19 +0700 Subject: [PATCH 20/36] refactor(FE): Refactor table column definitions to remove unused row parameter --- .../export/ProductionResultExportPDF.tsx | 14 ++-- .../filter/ProductionResultFilter.ts | 50 ++++++++++++ .../skeleton/ProductionResultSkeleton.tsx | 38 +++++++++ .../tab/ProductionResultTab.tsx | 79 +++++++++++++++++-- 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx b/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx index eabb03bf..76336569 100644 --- a/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx +++ b/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx @@ -66,7 +66,7 @@ const getBwTableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'woa', @@ -114,7 +114,7 @@ const getDepTableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'dep_kum', @@ -141,7 +141,7 @@ const getButiranTableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'butiran_utuh', @@ -196,7 +196,7 @@ const getKgTableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'kg_utuh', @@ -251,7 +251,7 @@ const getPersenTableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'persen_utuh', @@ -292,7 +292,7 @@ const getProduksi1TableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'hd', @@ -361,7 +361,7 @@ const getProduksi2TableColumns = (): PdfColumn[] => [ header: 'No', flex: 0.5, align: 'center', - cell: ({ row, index }) => index + 1, + cell: ({ index }) => index + 1, }, { key: 'fcr', diff --git a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts index e69de29b..1c1979eb 100644 --- a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts +++ b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts @@ -0,0 +1,50 @@ +import * as yup from 'yup'; + +export type ProductionResultFilterType = { + area_id: string | null; + location_id: string | null; + project_flock_id: string | null; + kandang_id: string | null; + date_start: string | null; + date_end: string | null; + sort_by: string | null; + show_unrecorded: boolean | null; +}; + +export const ProductionResultFilterSchema = yup.object({ + area_id: yup.string().nullable(), + location_id: yup.string().nullable(), + project_flock_id: yup.string().nullable(), + kandang_id: yup.string().nullable(), + date_start: yup + .string() + .nullable() + .test( + 'is-valid-date', + 'Tanggal mulai tidak valid', + (value) => !value || !isNaN(Date.parse(value)) + ), + date_end: yup + .string() + .nullable() + .test( + 'is-valid-date', + 'Tanggal akhir tidak valid', + (value) => !value || !isNaN(Date.parse(value)) + ) + .test( + 'is-after-start', + 'Tanggal akhir tidak boleh lebih awal dari tanggal mulai', + function (value) { + const { date_start } = this.parent; + if (!date_start || !value) return true; + return new Date(value) >= new Date(date_start); + } + ), + sort_by: yup.string().nullable(), + show_unrecorded: yup.boolean().nullable(), +}); + +export type ProductionResultFilterValues = yup.InferType< + typeof ProductionResultFilterSchema +>; diff --git a/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx index e69de29b..c8cd63d8 100644 --- a/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx +++ b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { ProductionResult } from '@/types/api/report/production-result'; +import { ColumnDef } from '@tanstack/react-table'; + +const ProductionResultSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default ProductionResultSkeleton; diff --git a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx index 84b21c41..ac5e76fd 100644 --- a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState } from 'react'; +import React, { useState } from 'react'; +import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX'; import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; @@ -161,11 +162,79 @@ const ProductionResultContent = () => { const exportToExcelHandler = async () => { setIsLoadingExportingToExcel(true); - await ProductionResultReportApi.exportProductionResultToExcel( - projectFlockKandangs - ); + try { + let projectFlockKandangsData: BaseProjectFlockKandang[] = []; - setIsLoadingExportingToExcel(false); + if (selectedProjectFlockKandang) { + const projectFlockKandangResponse = + await ProjectFlockKandangApi.getSingle( + selectedProjectFlockKandang?.value as number + ); + + projectFlockKandangsData = isResponseSuccess( + projectFlockKandangResponse + ) + ? [projectFlockKandangResponse.data] + : []; + } else { + const projectFlockKandangsResponse = + await ProjectFlockKandangApi.getAll({ + area_id: selectedArea?.value, + project_flock_id: selectedProjectFlock?.value, + }); + + projectFlockKandangsData = isResponseSuccess( + projectFlockKandangsResponse + ) + ? projectFlockKandangsResponse.data + : []; + } + + const productionResultData: ProductionResult[] = []; + + for (const kandang of projectFlockKandangsData) { + const getProductionResultPath = `${ProductionResultReportApi.basePath}/${kandang.id}?page=1&limit=100`; + const getProductionResultRes = await httpClient< + BaseApiResponse + >(getProductionResultPath); + + if (isResponseSuccess(getProductionResultRes)) { + productionResultData.push( + ...(getProductionResultRes.data?.map((result) => ({ + ...result, + project_flock: { + ...result.project_flock, + name: + selectedProjectFlock?.label || + kandang.project_flock?.name || + `Project Flock #${kandang.project_flock_id}`, + category: kandang.project_flock?.category || '', + kandang: { + ...result.project_flock?.kandang, + name: + kandang.kandang?.name || `Kandang #${kandang.kandang_id}`, + }, + }, + })) || []) + ); + } + } + + if (productionResultData.length === 0) { + toast.error('Tidak ada data untuk diexport.'); + setIsLoadingExportingToExcel(false); + return; + } + + await generateProductionResultExcel({ + data: productionResultData, + period: '', + }); + } catch { + toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); + } finally { + setIsLoadingExportingToExcel(false); + } }; const exportToPdfHandler = async () => { From 6595ff7a6ec7436bce4f829f7cfed37715fee4b2 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 15:03:24 +0700 Subject: [PATCH 21/36] refactor(FE): Add function to export production results to Excel --- .../export/ProductionResultExportXLSX.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx b/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx index e69de29b..af0380c0 100644 --- a/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx +++ b/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx @@ -0,0 +1,135 @@ +'use client'; + +import ExcelJS from 'exceljs'; +import { formatNumber } from '@/lib/helper'; +import { ProductionResult } from '@/types/api/report/production-result'; + +interface ProductionResultExportExcelParams { + data: ProductionResult[]; + period?: string; +} + +export const generateProductionResultExcel = async ( + params: ProductionResultExportExcelParams +): Promise => { + if (!params.data || params.data.length === 0) { + return; + } + + const workbook = new ExcelJS.Workbook(); + + // ===== PRODUCTION RESULT WORKSHEET ===== + const columns = [ + { header: 'No', key: 'no', width: 6 }, + { header: 'Project Flock', key: 'projectFlockName', width: 25 }, + { + header: 'Category', + key: 'projectFlockCategory', + width: 18, + }, + { header: 'Kandang', key: 'kandangName', width: 18 }, + { header: 'Week of Age (WOA)', key: 'woa', width: 20 }, + { header: 'Body Weight (BW)', key: 'bw', width: 18 }, + { header: 'Body Weight (Std BW)', key: 'stdBw', width: 22 }, + { header: 'Uniformity (%)', key: 'uniformity', width: 16 }, + { header: 'Uniformity Std (%)', key: 'stdUniformity', width: 20 }, + { header: 'Depletion Cumulative', key: 'depKum', width: 22 }, + { header: 'Depletion Standard', key: 'depStd', width: 20 }, + { header: 'Telur Utuh', key: 'butiranUtuh', width: 14 }, + { header: 'Telur Putih', key: 'butiranPutih', width: 14 }, + { header: 'Telur Retak', key: 'butiranRetak', width: 14 }, + { header: 'Telur Pecah', key: 'butiranPecah', width: 14 }, + { header: 'Jumlah Telur', key: 'butiranJumlah', width: 16 }, + { header: 'Total Telur', key: 'totalButir', width: 14 }, + { header: 'Utuh (Kg)', key: 'kgUtuh', width: 12 }, + { header: 'Putih (Kg)', key: 'kgPutih', width: 12 }, + { header: 'Retak (Kg)', key: 'kgRetak', width: 12 }, + { header: 'Pecah (Kg)', key: 'kgPecah', width: 12 }, + { header: 'Jumlah (Kg)', key: 'kgJumlah', width: 14 }, + { header: 'Total Weight (Kg)', key: 'totalKg', width: 20 }, + { header: 'Utuh (%)', key: 'persenUtuh', width: 12 }, + { header: 'Putih (%)', key: 'persenPutih', width: 12 }, + { header: 'Retak (%)', key: 'persenRetak', width: 12 }, + { header: 'Pecah (%)', key: 'persenPecah', width: 12 }, + { header: 'Hen Day (HD)', key: 'hd', width: 15 }, + { header: 'Hen Day Std (HD Std)', key: 'hdStd', width: 22 }, + { header: 'Feed Intake (FI)', key: 'fi', width: 18 }, + { header: 'Feed Intake Std (FI Std)', key: 'fiStd', width: 25 }, + { header: 'Egg Mass (EM)', key: 'em', width: 16 }, + { header: 'Egg Mass Std (EM Std)', key: 'emStd', width: 23 }, + { header: 'Egg Weight (EW)', key: 'ew', width: 18 }, + { header: 'Egg Weight Std (EW Std)', key: 'ewStd', width: 25 }, + { header: 'Feed Conversion Ratio (FCR)', key: 'fcr', width: 30 }, + { + header: 'Feed Conversion Ratio Std (FCR Std)', + key: 'fcrStd', + width: 35, + }, + { header: 'Hen House (HH)', key: 'hh', width: 18 }, + { header: 'Hen House Std (HH Std)', key: 'hhStd', width: 25 }, + ]; + + const worksheet = workbook.addWorksheet('Production Result'); + worksheet.columns = columns; + + // Add data rows + params.data.forEach((item: ProductionResult, index: number) => { + worksheet.addRow({ + no: index + 1, + projectFlockName: item.project_flock?.name || '', + projectFlockCategory: item.project_flock?.category || '', + kandangName: item.project_flock?.kandang?.name || '', + woa: formatNumber(item.woa || 0), + bw: formatNumber(item.bw || 0), + stdBw: formatNumber(item.std_bw || 0), + uniformity: formatNumber(item.uniformity || 0), + stdUniformity: item.std_uniformity || '', + depKum: formatNumber(item.dep_kum || 0), + depStd: formatNumber(item.dep_std || 0), + butiranUtuh: formatNumber(item.butiran_utuh || 0), + butiranPutih: formatNumber(item.butiran_putih || 0), + butiranRetak: formatNumber(item.butiran_retak || 0), + butiranPecah: formatNumber(item.butiran_pecah || 0), + butiranJumlah: formatNumber(item.butiran_jumlah || 0), + totalButir: formatNumber(item.total_butir || 0), + kgUtuh: formatNumber(item.kg_utuh || 0), + kgPutih: formatNumber(item.kg_putih || 0), + kgRetak: formatNumber(item.kg_retak || 0), + kgPecah: formatNumber(item.kg_pecah || 0), + kgJumlah: formatNumber(item.kg_jumlah || 0), + totalKg: formatNumber(item.total_kg || 0), + persenUtuh: formatNumber(item.persen_utuh || 0), + persenPutih: formatNumber(item.persen_putih || 0), + persenRetak: formatNumber(item.persen_retak || 0), + persenPecah: formatNumber(item.persen_pecah || 0), + hd: formatNumber(item.hd || 0), + hdStd: formatNumber(item.hd_std || 0), + fi: formatNumber(item.fi || 0), + fiStd: formatNumber(item.fi_std || 0), + em: formatNumber(item.em || 0), + emStd: formatNumber(item.em_std || 0), + ew: formatNumber(item.ew || 0), + ewStd: formatNumber(item.ew_std || 0), + fcr: formatNumber(item.fcr || 0), + fcrStd: formatNumber(item.fcr_std || 0), + hh: formatNumber(item.hh || 0), + hhStd: formatNumber(item.hh_std || 0), + }); + }); + + const currentDate = new Date().toISOString().split('T')[0]; + const filename = params.period + ? `laporan-hasil-produksi-${params.period}.xlsx` + : `laporan-hasil-produksi-${currentDate}.xlsx`; + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + window.URL.revokeObjectURL(url); +}; From 5d92e6774ec4d9a9396fe89f1193b926dc7aec94 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 15:14:00 +0700 Subject: [PATCH 22/36] feat(FE): Add stock field to StockLog type --- src/types/api/inventory/product.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/api/inventory/product.d.ts b/src/types/api/inventory/product.d.ts index f75e4060..134b982a 100644 --- a/src/types/api/inventory/product.d.ts +++ b/src/types/api/inventory/product.d.ts @@ -38,6 +38,7 @@ export type StockLog = { id: number; increase: number; decrease: number; + stock: number; loggable_type: string; loggable_id: number; notes: string; From 45ac8348fe84d949d63c4d0976a0a7a5d2cac7d1 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 15:23:17 +0700 Subject: [PATCH 23/36] feat(FE): Add "Stock Akhir" column to StockLogTable --- .../pages/inventory/product/detail/StockLogTable.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/pages/inventory/product/detail/StockLogTable.tsx b/src/components/pages/inventory/product/detail/StockLogTable.tsx index 96d3dda6..0a305659 100644 --- a/src/components/pages/inventory/product/detail/StockLogTable.tsx +++ b/src/components/pages/inventory/product/detail/StockLogTable.tsx @@ -35,6 +35,13 @@ const StockLogTable = ({ header: 'Gudang', accessorKey: 'warehouse_name', }, + { + header: 'Stock Akhir', + accessorKey: 'stock', + cell: (props) => { + return formatNumber(props.row.original.stock); + }, + }, { header: 'Peningkatan', accessorKey: 'increase', From cb171118ee2eab5d7d873994d4a9716758564337 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 15:32:11 +0700 Subject: [PATCH 24/36] refactor(FE): Restrict row selection to specific approval criteria --- .../project-flock/ProjectFlockTable.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index 4085bc56..5521fdbf 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -384,7 +384,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { id: 'select', header: ({ table }) => { const allRows = table.getRowModel().rows; - const selectableRows = allRows; + const selectableRows = allRows.filter((row) => { + const projectFlock = row.original; + return ( + projectFlock.approval?.step_number === 1 && + projectFlock.approval?.action !== 'REJECTED' + ); + }); const allSelected = selectableRows.every((row) => row.getIsSelected()) && @@ -398,6 +404,8 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { selectableRows.forEach((row) => row.toggleSelected(shouldSelect)); }; + const hasNoSelectableRows = selectableRows.length === 0; + return (
void }) => { checked={allSelected} indeterminate={someSelected} onChange={toggleSelectableRows} + disabled={hasNoSelectableRows} />
); @@ -845,6 +854,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { setSorting={setSorting} rowSelection={rowSelection} setRowSelection={setRowSelection} + enableRowSelection={(row) => { + const projectFlock = row.original; + return ( + projectFlock.approval?.step_number === 1 && + projectFlock.approval?.action !== 'REJECTED' + ); + }} withCheckbox className={{ containerClassName: cn('p-3', { From 6aae18df54524d3aee0933836dc818feb7926c7c Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 15:49:26 +0700 Subject: [PATCH 25/36] refactor(FE): Update import paths for finance and marketing tab stores --- .../pages/production/project-flock/ProjectFlockTable.tsx | 2 +- .../pages/production/project-flock/form/ProjectFlockForm.tsx | 2 +- src/components/pages/production/uniformity/UniformityTable.tsx | 2 +- .../pages/production/uniformity/form/UniformityForm.tsx | 2 +- .../pages/production/uniformity/form/UniformityPreviewForm.tsx | 2 +- .../pages/production/uniformity/form/UniformityResultForm.tsx | 2 +- src/components/pages/report/finance/FinanceTabs.tsx | 2 +- src/components/pages/report/finance/tab/CustomerPaymentTab.tsx | 2 +- src/components/pages/report/finance/tab/DebtSupplierTab.tsx | 2 +- .../pages/report/logistic-stock/LogisticStockTabs.tsx | 2 +- .../pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx | 2 +- src/components/pages/report/marketing/MarketingTabs.tsx | 2 +- src/components/pages/report/marketing/tab/DailyMarketingTab.tsx | 2 +- src/components/pages/report/marketing/tab/HppPerKandangTab.tsx | 2 +- .../{ => production}/project-flock/project-flock.store.ts | 2 +- .../project-flock/slices/project-flock.slice.ts | 0 .../{ => production}/uniformity/slices/uniformity.slice.ts | 0 src/stores/{ => production}/uniformity/uniformity.store.ts | 2 +- src/stores/{ => report}/finance-tab/finance-tab.store.ts | 0 .../{ => report}/logistic-stock-tab/logistic-stock-tab.store.ts | 0 src/stores/{ => report}/marketing-tab/marketing-tab.store.ts | 0 21 files changed, 16 insertions(+), 16 deletions(-) rename src/stores/{ => production}/project-flock/project-flock.store.ts (79%) rename src/stores/{ => production}/project-flock/slices/project-flock.slice.ts (100%) rename src/stores/{ => production}/uniformity/slices/uniformity.slice.ts (100%) rename src/stores/{ => production}/uniformity/uniformity.store.ts (80%) rename src/stores/{ => report}/finance-tab/finance-tab.store.ts (100%) rename src/stores/{ => report}/logistic-stock-tab/logistic-stock-tab.store.ts (100%) rename src/stores/{ => report}/marketing-tab/marketing-tab.store.ts (100%) diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index 5521fdbf..e8280fa8 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -34,7 +34,7 @@ import StatusBadge from '@/components/helper/StatusBadge'; import PopoverButton from '@/components/popover/PopoverButton'; import PopoverContent from '@/components/popover/PopoverContent'; import ProjectFlockConfirmationModal from './ProjectFlockConfirmationModal'; -import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store'; +import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store'; import { ProjectFlockFormValues } from './form/ProjectFlockForm.schema'; const RowOptionsMenu = ({ diff --git a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx index d56550a6..57cde405 100644 --- a/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx +++ b/src/components/pages/production/project-flock/form/ProjectFlockForm.tsx @@ -52,7 +52,7 @@ import Table from '@/components/Table'; import { ColumnDef } from '@tanstack/react-table'; import StatusBadge from '@/components/helper/StatusBadge'; import { getUniqueFormikErrors } from '@/lib/formik-helper'; -import { useProjectFlockStore } from '@/stores/project-flock/project-flock.store'; +import { useProjectFlockStore } from '@/stores/production/project-flock/project-flock.store'; interface ProjectFlockFormProps { formType?: 'add' | 'edit' | 'detail'; diff --git a/src/components/pages/production/uniformity/UniformityTable.tsx b/src/components/pages/production/uniformity/UniformityTable.tsx index 39112b47..3473967e 100644 --- a/src/components/pages/production/uniformity/UniformityTable.tsx +++ b/src/components/pages/production/uniformity/UniformityTable.tsx @@ -26,7 +26,7 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import toast from 'react-hot-toast'; import UniformityTableSkeleton from '@/components/pages/production/uniformity/skeleton/UniformityTableSkeleton'; import RequirePermission from '@/components/helper/RequirePermission'; -import { useUniformityStore } from '@/stores/uniformity/uniformity.store'; +import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store'; import FloatingActionsButton from '@/components/FloatingActionsButton'; import Modal from '@/components/Modal'; import SelectInput, { diff --git a/src/components/pages/production/uniformity/form/UniformityForm.tsx b/src/components/pages/production/uniformity/form/UniformityForm.tsx index 8ab62d85..33b649c4 100644 --- a/src/components/pages/production/uniformity/form/UniformityForm.tsx +++ b/src/components/pages/production/uniformity/form/UniformityForm.tsx @@ -7,7 +7,7 @@ import { Icon } from '@iconify/react'; import { toast } from 'react-hot-toast'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import { useUiStore } from '@/stores/ui/ui.store'; -import { useUniformityStore } from '@/stores/uniformity/uniformity.store'; +import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store'; import Button from '@/components/Button'; import DateInput from '@/components/input/DateInput'; diff --git a/src/components/pages/production/uniformity/form/UniformityPreviewForm.tsx b/src/components/pages/production/uniformity/form/UniformityPreviewForm.tsx index 3cc120fd..3ca24952 100644 --- a/src/components/pages/production/uniformity/form/UniformityPreviewForm.tsx +++ b/src/components/pages/production/uniformity/form/UniformityPreviewForm.tsx @@ -7,7 +7,7 @@ import Button from '@/components/Button'; import Tooltip from '@/components/Tooltip'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import { useUiStore } from '@/stores/ui/ui.store'; -import { useUniformityStore } from '@/stores/uniformity/uniformity.store'; +import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store'; import RequirePermission from '@/components/helper/RequirePermission'; import Table from '@/components/Table'; import { diff --git a/src/components/pages/production/uniformity/form/UniformityResultForm.tsx b/src/components/pages/production/uniformity/form/UniformityResultForm.tsx index eaf51103..108cb4f8 100644 --- a/src/components/pages/production/uniformity/form/UniformityResultForm.tsx +++ b/src/components/pages/production/uniformity/form/UniformityResultForm.tsx @@ -7,7 +7,7 @@ import Button from '@/components/Button'; import Tooltip from '@/components/Tooltip'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import { useUiStore } from '@/stores/ui/ui.store'; -import { useUniformityStore } from '@/stores/uniformity/uniformity.store'; +import { useUniformityStore } from '@/stores/production/uniformity/uniformity.store'; import RequirePermission from '@/components/helper/RequirePermission'; import Table from '@/components/Table'; import { useRouter } from 'next/navigation'; diff --git a/src/components/pages/report/finance/FinanceTabs.tsx b/src/components/pages/report/finance/FinanceTabs.tsx index ffb0d3f1..84d610a5 100644 --- a/src/components/pages/report/finance/FinanceTabs.tsx +++ b/src/components/pages/report/finance/FinanceTabs.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; -import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store'; +import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; const FinanceTabs = () => { const [activeTabId, setActiveTabId] = useState('1'); diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index 3680a41c..03b41e10 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -36,7 +36,7 @@ import { } from '@/components/pages/report/finance/filter/CustomerPaymentFilter'; import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; -import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store'; +import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import { OptionType } from '@/components/table/TableRowSizeSelector'; import { Color } from '@/types/theme'; diff --git a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx index 5e7781bf..bd71f02a 100644 --- a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx +++ b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx @@ -33,7 +33,7 @@ import { Color } from '@/types/theme'; import { Supplier } from '@/types/api/master-data/supplier'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; -import { useFinanceTabStore } from '@/stores/finance-tab/finance-tab.store'; +import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; import StatusBadge from '@/components/helper/StatusBadge'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; diff --git a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx index f06e63dc..6996db55 100644 --- a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx +++ b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab'; -import { useLogisticStockTabStore } from '@/stores/logistic-stock-tab/logistic-stock-tab.store'; +import { useLogisticStockTabStore } from '@/stores/report/logistic-stock-tab/logistic-stock-tab.store'; const LogisticStockTabs = () => { const [activeTabId, setActiveTabId] = useState('1'); diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 23fb067e..f9251f4a 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -32,7 +32,7 @@ import { } from '@/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; -import { useLogisticStockTabStore } from '@/stores/logistic-stock-tab/logistic-stock-tab.store'; +import { useLogisticStockTabStore } from '@/stores/report/logistic-stock-tab/logistic-stock-tab.store'; import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; interface PurchasesPerSupplierTabProps { diff --git a/src/components/pages/report/marketing/MarketingTabs.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx index de449f9c..d07c7d1b 100644 --- a/src/components/pages/report/marketing/MarketingTabs.tsx +++ b/src/components/pages/report/marketing/MarketingTabs.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab'; import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; -import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; +import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; const MarketingReportContent = () => { const [activeTabId, setActiveTabId] = useState('1'); diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index a687fdf5..9c03de7a 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -37,7 +37,7 @@ import { import SelectInput from '@/components/input/SelectInput'; import Modal, { useModal } from '@/components/Modal'; import { cn } from '@/lib/helper'; -import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; +import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton'; import { useEffect as useEffectHook } from 'react'; import { httpClient } from '@/services/http/client'; diff --git a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx index 514edcb9..0650c19a 100644 --- a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx +++ b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx @@ -33,7 +33,7 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; import Modal, { useModal } from '@/components/Modal'; import { cn } from '@/lib/helper'; -import { useMarketingTabStore } from '@/stores/marketing-tab/marketing-tab.store'; +import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; import HppPerKandangSkeleton from '@/components/pages/report/marketing/skeleton/HppPerKandangSkeleton'; import { useEffect as useEffectHook } from 'react'; diff --git a/src/stores/project-flock/project-flock.store.ts b/src/stores/production/project-flock/project-flock.store.ts similarity index 79% rename from src/stores/project-flock/project-flock.store.ts rename to src/stores/production/project-flock/project-flock.store.ts index 61efcb97..97402132 100644 --- a/src/stores/project-flock/project-flock.store.ts +++ b/src/stores/production/project-flock/project-flock.store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { createProjectFlockSlice } from '@/stores/project-flock/slices/project-flock.slice'; +import { createProjectFlockSlice } from '@/stores/production/project-flock/slices/project-flock.slice'; import { ProjectFlockSlice } from '@/types/stores'; export type ProjectFlockStore = ProjectFlockSlice; diff --git a/src/stores/project-flock/slices/project-flock.slice.ts b/src/stores/production/project-flock/slices/project-flock.slice.ts similarity index 100% rename from src/stores/project-flock/slices/project-flock.slice.ts rename to src/stores/production/project-flock/slices/project-flock.slice.ts diff --git a/src/stores/uniformity/slices/uniformity.slice.ts b/src/stores/production/uniformity/slices/uniformity.slice.ts similarity index 100% rename from src/stores/uniformity/slices/uniformity.slice.ts rename to src/stores/production/uniformity/slices/uniformity.slice.ts diff --git a/src/stores/uniformity/uniformity.store.ts b/src/stores/production/uniformity/uniformity.store.ts similarity index 80% rename from src/stores/uniformity/uniformity.store.ts rename to src/stores/production/uniformity/uniformity.store.ts index c8d759d6..740d10b6 100644 --- a/src/stores/uniformity/uniformity.store.ts +++ b/src/stores/production/uniformity/uniformity.store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { createUniformitySlice } from '@/stores/uniformity/slices/uniformity.slice'; +import { createUniformitySlice } from '@/stores/production/uniformity/slices/uniformity.slice'; import { UniformitySlice } from '@/types/stores'; export type UniformityStore = UniformitySlice; diff --git a/src/stores/finance-tab/finance-tab.store.ts b/src/stores/report/finance-tab/finance-tab.store.ts similarity index 100% rename from src/stores/finance-tab/finance-tab.store.ts rename to src/stores/report/finance-tab/finance-tab.store.ts diff --git a/src/stores/logistic-stock-tab/logistic-stock-tab.store.ts b/src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts similarity index 100% rename from src/stores/logistic-stock-tab/logistic-stock-tab.store.ts rename to src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts diff --git a/src/stores/marketing-tab/marketing-tab.store.ts b/src/stores/report/marketing-tab/marketing-tab.store.ts similarity index 100% rename from src/stores/marketing-tab/marketing-tab.store.ts rename to src/stores/report/marketing-tab/marketing-tab.store.ts From dbb523c7106560d57f86658a70184f4d0608a719 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 16:16:40 +0700 Subject: [PATCH 26/36] refactor(FE): Refactor tab store to use a unified ReportTabStore --- .../pages/report/finance/FinanceTabs.tsx | 4 +- .../report/finance/tab/CustomerPaymentTab.tsx | 6 +-- .../report/finance/tab/DebtSupplierTab.tsx | 6 +-- .../logistic-stock/LogisticStockTabs.tsx | 4 +- .../tab/PurchasesPerSupplierTab.tsx | 10 ++-- .../pages/report/marketing/MarketingTabs.tsx | 4 +- .../marketing/tab/DailyMarketingTab.tsx | 8 ++- .../report/marketing/tab/HppPerKandangTab.tsx | 8 ++- .../report/finance-tab/finance-tab.store.ts | 51 ------------------- .../logistic-stock-tab.store.ts | 51 ------------------- .../marketing-tab/marketing-tab.store.ts | 51 ------------------- src/stores/report/report-tab.store.ts | 18 +++++++ src/stores/report/slices/report-tab.slice.ts | 37 ++++++++++++++ 13 files changed, 76 insertions(+), 182 deletions(-) delete mode 100644 src/stores/report/finance-tab/finance-tab.store.ts delete mode 100644 src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts delete mode 100644 src/stores/report/marketing-tab/marketing-tab.store.ts create mode 100644 src/stores/report/report-tab.store.ts create mode 100644 src/stores/report/slices/report-tab.slice.ts diff --git a/src/components/pages/report/finance/FinanceTabs.tsx b/src/components/pages/report/finance/FinanceTabs.tsx index 84d610a5..de924f62 100644 --- a/src/components/pages/report/finance/FinanceTabs.tsx +++ b/src/components/pages/report/finance/FinanceTabs.tsx @@ -4,11 +4,11 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import CustomerPaymentTab from '@/components/pages/report/finance/tab/CustomerPaymentTab'; import DebtSupplierTab from '@/components/pages/report/finance/tab/DebtSupplierTab'; -import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; const FinanceTabs = () => { const [activeTabId, setActiveTabId] = useState('1'); - const tabActions = useFinanceTabStore((state) => state.tabActions); + const tabActions = useReportTabStore((state) => state.tabActions); const tabs = [ { diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index 03b41e10..1443fa0b 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -36,7 +36,7 @@ import { } from '@/components/pages/report/finance/filter/CustomerPaymentFilter'; import { generateCustomerPaymentExcel } from '@/components/pages/report/finance/export/CustomerPaymentExportXLSX'; import { generateCustomerPaymentPDF } from '@/components/pages/report/finance/export/CustomerPaymentExportPDF'; -import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; import CustomerSupplierSkeleton from '@/components/pages/report/finance/skeleton/CustomerSupplierSkeleton'; import { OptionType } from '@/components/table/TableRowSizeSelector'; import { Color } from '@/types/theme'; @@ -375,8 +375,8 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { }, [customerPaymentExport, filterParams, customerOptions]); // ===== REGISTER TAB ACTIONS TO STORE ===== - const setTabActions = useFinanceTabStore((state) => state.setTabActions); - const clearTabActions = useFinanceTabStore((state) => state.clearTabActions); + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); useEffect(() => { setTabActions( diff --git a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx index bd71f02a..44677313 100644 --- a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx +++ b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx @@ -33,7 +33,7 @@ import { Color } from '@/types/theme'; import { Supplier } from '@/types/api/master-data/supplier'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; -import { useFinanceTabStore } from '@/stores/report/finance-tab/finance-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; import StatusBadge from '@/components/helper/StatusBadge'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; @@ -271,8 +271,8 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { }, [debtSupplierExport]); // ===== REGISTER TAB ACTIONS TO STORE ===== - const setTabActions = useFinanceTabStore((state) => state.setTabActions); - const clearTabActions = useFinanceTabStore((state) => state.clearTabActions); + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); useEffect(() => { setTabActions( diff --git a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx index 6996db55..1e3f4109 100644 --- a/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx +++ b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx @@ -3,11 +3,11 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import PurchasesPerSupplierTab from '@/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab'; -import { useLogisticStockTabStore } from '@/stores/report/logistic-stock-tab/logistic-stock-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; const LogisticStockTabs = () => { const [activeTabId, setActiveTabId] = useState('1'); - const tabActions = useLogisticStockTabStore((state) => state.tabActions); + const tabActions = useReportTabStore((state) => state.tabActions); const tabs = [ { diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index f9251f4a..5e2c4e27 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -32,7 +32,7 @@ import { } from '@/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; -import { useLogisticStockTabStore } from '@/stores/report/logistic-stock-tab/logistic-stock-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; interface PurchasesPerSupplierTabProps { @@ -486,12 +486,8 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { ]); // ===== REGISTER TAB ACTIONS TO STORE ===== - const setTabActions = useLogisticStockTabStore( - (state) => state.setTabActions - ); - const clearTabActions = useLogisticStockTabStore( - (state) => state.clearTabActions - ); + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); useEffect(() => { setTabActions( diff --git a/src/components/pages/report/marketing/MarketingTabs.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx index d07c7d1b..8a02a0c2 100644 --- a/src/components/pages/report/marketing/MarketingTabs.tsx +++ b/src/components/pages/report/marketing/MarketingTabs.tsx @@ -4,11 +4,11 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab'; import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; -import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; const MarketingReportContent = () => { const [activeTabId, setActiveTabId] = useState('1'); - const tabActions = useMarketingTabStore((state) => state.tabActions); + const tabActions = useReportTabStore((state) => state.tabActions); const tabs = [ { diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index 9c03de7a..df06c83c 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -37,7 +37,7 @@ import { import SelectInput from '@/components/input/SelectInput'; import Modal, { useModal } from '@/components/Modal'; import { cn } from '@/lib/helper'; -import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; import DailyMarketingReportSkeleton from '@/components/pages/report/marketing/skeleton/DailyMarketingSkeleton'; import { useEffect as useEffectHook } from 'react'; import { httpClient } from '@/services/http/client'; @@ -390,10 +390,8 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { }, [dailyMarketingsExport, summaryTotal]); // ===== REGISTER TAB ACTIONS TO STORE ===== - const setTabActions = useMarketingTabStore((state) => state.setTabActions); - const clearTabActions = useMarketingTabStore( - (state) => state.clearTabActions - ); + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); useEffectHook(() => { setTabActions( diff --git a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx index 0650c19a..d3d4bfac 100644 --- a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx +++ b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx @@ -33,7 +33,7 @@ import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputRadio from '@/components/input/SelectInputRadio'; import Modal, { useModal } from '@/components/Modal'; import { cn } from '@/lib/helper'; -import { useMarketingTabStore } from '@/stores/report/marketing-tab/marketing-tab.store'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; import HppPerKandangSkeleton from '@/components/pages/report/marketing/skeleton/HppPerKandangSkeleton'; import { useEffect as useEffectHook } from 'react'; @@ -487,10 +487,8 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => { ]); // ===== REGISTER TAB ACTIONS TO STORE ===== - const setTabActions = useMarketingTabStore((state) => state.setTabActions); - const clearTabActions = useMarketingTabStore( - (state) => state.clearTabActions - ); + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); useEffectHook(() => { setTabActions( diff --git a/src/stores/report/finance-tab/finance-tab.store.ts b/src/stores/report/finance-tab/finance-tab.store.ts deleted file mode 100644 index 9b5cf096..00000000 --- a/src/stores/report/finance-tab/finance-tab.store.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -export type FinanceTabActionsSlice = { - // State - actions per tab ID - tabActions: Record; - - // Actions - setTabActions: (tabId: string, actions: ReactNode) => void; - clearTabActions: (tabId: string) => void; - clearAllTabActions: () => void; -}; - -export const useFinanceTabStore = create()( - devtools( - (set) => ({ - tabActions: {}, - - setTabActions: (tabId, actions) => - set( - (state) => ({ - tabActions: { - ...state.tabActions, - [tabId]: actions, - }, - }), - false, - 'setTabActions' - ), - - clearTabActions: (tabId) => - set( - (state) => { - const { [tabId]: _, ...rest } = state.tabActions; - return { tabActions: rest }; - }, - false, - 'clearTabActions' - ), - - clearAllTabActions: () => - set({ tabActions: {} }, false, 'clearAllTabActions'), - }), - { - name: 'FinanceTabStore', - } - ) -); diff --git a/src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts b/src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts deleted file mode 100644 index f9e142b1..00000000 --- a/src/stores/report/logistic-stock-tab/logistic-stock-tab.store.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -export type LogisticStockTabActionsSlice = { - // State - actions per tab ID - tabActions: Record; - - // Actions - setTabActions: (tabId: string, actions: ReactNode) => void; - clearTabActions: (tabId: string) => void; - clearAllTabActions: () => void; -}; - -export const useLogisticStockTabStore = create()( - devtools( - (set) => ({ - tabActions: {}, - - setTabActions: (tabId, actions) => - set( - (state) => ({ - tabActions: { - ...state.tabActions, - [tabId]: actions, - }, - }), - false, - 'setTabActions' - ), - - clearTabActions: (tabId) => - set( - (state) => { - const { [tabId]: _, ...rest } = state.tabActions; - return { tabActions: rest }; - }, - false, - 'clearTabActions' - ), - - clearAllTabActions: () => - set({ tabActions: {} }, false, 'clearAllTabActions'), - }), - { - name: 'LogisticStockTabStore', - } - ) -); diff --git a/src/stores/report/marketing-tab/marketing-tab.store.ts b/src/stores/report/marketing-tab/marketing-tab.store.ts deleted file mode 100644 index 153bbb8d..00000000 --- a/src/stores/report/marketing-tab/marketing-tab.store.ts +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { ReactNode } from 'react'; -import { create } from 'zustand'; -import { devtools } from 'zustand/middleware'; - -export type MarketingTabActionsSlice = { - // State - actions per tab ID - tabActions: Record; - - // Actions - setTabActions: (tabId: string, actions: ReactNode) => void; - clearTabActions: (tabId: string) => void; - clearAllTabActions: () => void; -}; - -export const useMarketingTabStore = create()( - devtools( - (set) => ({ - tabActions: {}, - - setTabActions: (tabId, actions) => - set( - (state) => ({ - tabActions: { - ...state.tabActions, - [tabId]: actions, - }, - }), - false, - 'setTabActions' - ), - - clearTabActions: (tabId) => - set( - (state) => { - const { [tabId]: _, ...rest } = state.tabActions; - return { tabActions: rest }; - }, - false, - 'clearTabActions' - ), - - clearAllTabActions: () => - set({ tabActions: {} }, false, 'clearAllTabActions'), - }), - { - name: 'MarketingTabStore', - } - ) -); diff --git a/src/stores/report/report-tab.store.ts b/src/stores/report/report-tab.store.ts new file mode 100644 index 00000000..76a2c684 --- /dev/null +++ b/src/stores/report/report-tab.store.ts @@ -0,0 +1,18 @@ +'use client'; + +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { createReportTabSlice, ReportTabSlice } from '@/stores/report/slices/report-tab.slice'; + +export type ReportTabStore = ReportTabSlice; + +export const useReportTabStore = create()( + devtools( + (...args) => ({ + ...createReportTabSlice(...args), + }), + { + name: 'ReportTabStore', + } + ) +); diff --git a/src/stores/report/slices/report-tab.slice.ts b/src/stores/report/slices/report-tab.slice.ts new file mode 100644 index 00000000..6582eaed --- /dev/null +++ b/src/stores/report/slices/report-tab.slice.ts @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { StateCreator } from 'zustand'; + +export type ReportTabSlice = { + // State - actions per tab ID + tabActions: Record; + + // Actions + setTabActions: (tabId: string, actions: ReactNode) => void; + clearTabActions: (tabId: string) => void; + clearAllTabActions: () => void; +}; + +export const createReportTabSlice: StateCreator< + ReportTabSlice, + [], + [], + ReportTabSlice +> = (set) => ({ + tabActions: {}, + + setTabActions: (tabId, actions) => + set((state) => ({ + tabActions: { + ...state.tabActions, + [tabId]: actions, + }, + })), + + clearTabActions: (tabId) => + set((state) => { + const { [tabId]: _, ...rest } = state.tabActions; + return { tabActions: rest }; + }), + + clearAllTabActions: () => set({ tabActions: {} }), +}); From 211622c7b06fa5d79c3a2d874ecba619c45f5d84 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 12 Feb 2026 16:19:40 +0700 Subject: [PATCH 27/36] refactor(FE): Format import statements in report-tab.store.ts --- src/stores/report/report-tab.store.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/stores/report/report-tab.store.ts b/src/stores/report/report-tab.store.ts index 76a2c684..aad47d17 100644 --- a/src/stores/report/report-tab.store.ts +++ b/src/stores/report/report-tab.store.ts @@ -2,7 +2,10 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { createReportTabSlice, ReportTabSlice } from '@/stores/report/slices/report-tab.slice'; +import { + createReportTabSlice, + ReportTabSlice, +} from '@/stores/report/slices/report-tab.slice'; export type ReportTabStore = ReportTabSlice; From 5c00893ea3fbf4db448fe34b8d2812e1a0f471e3 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:24:42 +0700 Subject: [PATCH 28/36] refactor(FE): Refactor production result components and improve UI --- src/app/report/production-result/page.tsx | 4 +- ...oductionResultProjectFlockKandangTable.tsx | 137 +-- .../ProductionResultTabs.tsx | 39 + .../filter/ProductionResultFilter.ts | 41 +- .../skeleton/ProductionResultSkeleton.tsx | 15 +- .../tab/ProductionResultTab.tsx | 919 +++++++++++------- 6 files changed, 682 insertions(+), 473 deletions(-) create mode 100644 src/components/pages/report/production-result/ProductionResultTabs.tsx diff --git a/src/app/report/production-result/page.tsx b/src/app/report/production-result/page.tsx index fb8f2a0c..4c9ea02b 100644 --- a/src/app/report/production-result/page.tsx +++ b/src/app/report/production-result/page.tsx @@ -1,9 +1,9 @@ -import ProductionResultContent from '@/components/pages/report/production-result/tab/ProductionResultTab'; +import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs'; const ProductionResultReportPage = () => { return (
- +
); }; diff --git a/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx b/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx index e1dfd515..ded97d02 100644 --- a/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx +++ b/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx @@ -4,12 +4,10 @@ import { useEffect, useState } from 'react'; import useSWR from 'swr'; import { ColumnDef, SortingState } from '@tanstack/react-table'; -import { Icon } from '@iconify/react'; import Table from '@/components/Table'; import Card from '@/components/Card'; -import Collapse from '@/components/Collapse'; -import { cn, formatNumber } from '@/lib/helper'; +import { formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { ProductionResult } from '@/types/api/report/production-result'; import { useTableFilter } from '@/services/hooks/useTableFilter'; @@ -52,8 +50,6 @@ const ProductionResultProjectFlockKandangTable = ({ } ); - const [open, setOpen] = useState(false); - const [sorting, setSorting] = useState([]); const productionResultColumns: ColumnDef[] = [ @@ -270,93 +266,60 @@ const ProductionResultProjectFlockKandangTable = ({ } }, [sorting]); - useEffect(() => { - if (!open) { - setOpen( + return ( + 0 : false - ); - } - }, [productionResults, isResponseSuccess]); - - return ( - - -
{kandangName}
- - - + + data={ + isResponseSuccess(productionResults) ? productionResults?.data : [] } - className='w-full!' - titleClassName='w-full p-0!' - > -
- {/*
-
- -
-
*/} - - - data={ - isResponseSuccess(productionResults) - ? productionResults?.data - : [] - } - columns={productionResultColumns} - pageSize={tableFilterState.pageSize} - onPageSizeChange={setPageSize} - rowOptions={[10, 20, 50, 100]} - page={ - isResponseSuccess(productionResults) - ? productionResults?.meta?.page - : 0 - } - totalItems={ - isResponseSuccess(productionResults) - ? productionResults?.meta?.total_results - : 0 - } - onPageChange={setPage} - isLoading={isLoadingProductionResults} - sorting={sorting} - setSorting={setSorting} - renderFooter={false} - className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(productionResults) && - productionResults?.data?.length === 0, - }), - headerColumnClassName: - 'px-4 py-3 border-x border-base-content/10 text-base-content/50', - }} - /> -
-
+ columns={productionResultColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={ + isResponseSuccess(productionResults) + ? productionResults?.meta?.page + : 0 + } + totalItems={ + isResponseSuccess(productionResults) + ? productionResults?.meta?.total_results + : 0 + } + onPageChange={setPage} + isLoading={isLoadingProductionResults} + sorting={sorting} + setSorting={setSorting} + renderFooter={false} + className={{ + containerClassName: 'w-full mb-0!', + tableWrapperClassName: + 'overflow-x-auto rounded-tr-none rounded-tl-none', + tableClassName: 'w-full table-auto text-sm', + headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + }} + />
); }; diff --git a/src/components/pages/report/production-result/ProductionResultTabs.tsx b/src/components/pages/report/production-result/ProductionResultTabs.tsx new file mode 100644 index 00000000..2bd7765f --- /dev/null +++ b/src/components/pages/report/production-result/ProductionResultTabs.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; +import Tabs from '@/components/Tabs'; +import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultTab'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; + +const ProductionResultTabs = () => { + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useReportTabStore((state) => state.tabActions); + + const tabs = [ + { + id: '1', + label: 'Hasil Produksi', + content: , + }, + ]; + + return ( +
+ +
+ ); +}; + +export default ProductionResultTabs; diff --git a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts index 1c1979eb..3281bb19 100644 --- a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts +++ b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts @@ -1,48 +1,17 @@ import * as yup from 'yup'; -export type ProductionResultFilterType = { +export type ProductionResultFilterProps = { area_id: string | null; location_id: string | null; project_flock_id: string | null; kandang_id: string | null; - date_start: string | null; - date_end: string | null; - sort_by: string | null; - show_unrecorded: boolean | null; }; export const ProductionResultFilterSchema = yup.object({ - area_id: yup.string().nullable(), - location_id: yup.string().nullable(), - project_flock_id: yup.string().nullable(), - kandang_id: yup.string().nullable(), - date_start: yup - .string() - .nullable() - .test( - 'is-valid-date', - 'Tanggal mulai tidak valid', - (value) => !value || !isNaN(Date.parse(value)) - ), - date_end: yup - .string() - .nullable() - .test( - 'is-valid-date', - 'Tanggal akhir tidak valid', - (value) => !value || !isNaN(Date.parse(value)) - ) - .test( - 'is-after-start', - 'Tanggal akhir tidak boleh lebih awal dari tanggal mulai', - function (value) { - const { date_start } = this.parent; - if (!date_start || !value) return true; - return new Date(value) >= new Date(date_start); - } - ), - sort_by: yup.string().nullable(), - show_unrecorded: yup.boolean().nullable(), + area_id: yup.string().required('Area wajib dipilih'), + location_id: yup.string().required('Lokasi wajib dipilih'), + project_flock_id: yup.string().required('Project Flock wajib dipilih'), + kandang_id: yup.string().required('Kandang wajib dipilih'), }); export type ProductionResultFilterValues = yup.InferType< diff --git a/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx index c8cd63d8..07d33233 100644 --- a/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx +++ b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx @@ -4,13 +4,26 @@ import Table from '@/components/Table'; import { ProductionResult } from '@/types/api/report/production-result'; import { ColumnDef } from '@tanstack/react-table'; +type ProductionResultColumn = + | ColumnDef + | { + header: string; + columns: Array<{ + header: string; + accessorKey?: string; + cell?: (props: { + row: { original: ProductionResult }; + }) => React.ReactNode; + }>; + }; + const ProductionResultSkeleton = ({ columns, icon, title, subtitle, }: { - columns: ColumnDef[]; + columns: ProductionResultColumn[]; icon: React.ReactNode; title: string; subtitle: string; diff --git a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx index ac5e76fd..2d7915e8 100644 --- a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx @@ -1,6 +1,7 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX'; import toast from 'react-hot-toast'; @@ -13,8 +14,9 @@ import SelectInput, { } from '@/components/input/SelectInput'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; -import Card from '@/components/Card'; import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable'; +import { useFormik } from 'formik'; +import { ProductionResultFilterSchema } from '@/components/pages/report/production-result/filter/ProductionResultFilter'; import { BaseKandang } from '@/types/api/master-data/kandang'; import { AreaApi, LocationApi } from '@/services/api/master-data'; @@ -26,87 +28,248 @@ import { BaseProjectFlockKandang, ProjectFlockKandang, } from '@/types/api/production/project-flock-kandang'; -import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import Pagination from '@/components/Pagination'; +import { isResponseSuccess } from '@/lib/api-helper'; import { ProductionResultReportApi } from '@/services/api/report/production-result'; import { BaseApiResponse } from '@/types/api/api-general'; import { httpClient } from '@/services/http/client'; +import { ColumnDef } from '@tanstack/react-table'; import { ProductionResult } from '@/types/api/report/production-result'; import ProductionResultReportPDF from '../export/ProductionResultExportPDF'; import { pdf } from '@react-pdf/renderer'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; +import Modal, { useModal } from '@/components/Modal'; +import { cn, formatNumber } from '@/lib/helper'; +import Pagination from '@/components/Pagination'; +import ProductionResultSkeleton from '@/components/pages/report/production-result/skeleton/ProductionResultSkeleton'; -const ProductionResultContent = () => { - const [projectFlockKandangs, setProjectFlockKandangs] = useState< - ProjectFlockKandang[] | null - >(null); - const [projectFlockKandangMetadata, setProjectFlockKandangMetadata] = - useState< - | { - page: number; - limit: number; - total_pages: number; - total_results: number; - } - | undefined - >(undefined); +interface ProductionResultTabProps { + tabId: string; +} +interface FilterParams { + area_id?: string; + location_id?: string; + project_flock_id?: string; + project_flock_kandang_id?: string; +} + +type ProductionResultFilterFormValues = { + area_id: OptionType | null; + location_id: OptionType | null; + project_flock_id: OptionType | null; + kandang_id: OptionType | null; +}; + +const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { + // ===== STATE MANAGEMENT ===== + const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); + const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); + const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; + + // ===== SUBMISSION STATE ===== + const [isSubmitted, setIsSubmitted] = useState(false); + const [filterParams, setFilterParams] = useState({}); + + // ===== PAGINATION STATE ===== const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); - const [isLoadingSearch, setIsLoadingSearch] = useState(false); + const filterModal = useModal(); - const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = - useState(false); + // ===== TABLE COLUMNS ===== + const productionResultColumns: ColumnDef[] = [ + { + header: 'No', + cell: (props) => props.row.index + 1, + }, + { + accessorKey: 'woa', + header: 'WOA', + }, + { + accessorKey: 'bw', + header: 'BW', + cell: (props) => formatNumber(props.row.original.bw), + }, + { + accessorKey: 'std_bw', + header: 'STD BW', + cell: (props) => formatNumber(props.row.original.std_bw), + }, + { + accessorKey: 'uniformity', + header: 'Uniformity', + cell: (props) => formatNumber(props.row.original.uniformity), + }, + { + accessorKey: 'std_uniformity', + header: 'STD Uniformity', + }, + { + accessorKey: 'dep_kum', + header: 'Dep Kum', + cell: (props) => formatNumber(props.row.original.dep_kum), + }, + { + accessorKey: 'dep_std', + header: 'Dep STD', + cell: (props) => formatNumber(props.row.original.dep_std), + }, + { + header: 'Butiran', + columns: [ + { + accessorKey: 'butiran_utuh', + header: 'Utuh', + cell: (props) => formatNumber(props.row.original.butiran_utuh), + }, + { + accessorKey: 'butiran_putih', + header: 'Putih', + cell: (props) => formatNumber(props.row.original.butiran_putih), + }, + { + accessorKey: 'butiran_retak', + header: 'Retak', + cell: (props) => formatNumber(props.row.original.butiran_retak), + }, + { + accessorKey: 'butiran_pecah', + header: 'Pecah', + cell: (props) => formatNumber(props.row.original.butiran_pecah), + }, + { + accessorKey: 'butiran_jumlah', + header: 'Jumlah (Butir)', + cell: (props) => formatNumber(props.row.original.butiran_jumlah), + }, + { + accessorKey: 'total_butir', + header: 'Total Butir', + cell: (props) => formatNumber(props.row.original.total_butir), + }, + ], + }, + { + header: 'Kg', + columns: [ + { + accessorKey: 'kg_utuh', + header: 'Utuh (Kg)', + cell: (props) => formatNumber(props.row.original.kg_utuh), + }, + { + accessorKey: 'kg_putih', + header: 'Putih (Kg)', + cell: (props) => formatNumber(props.row.original.kg_putih), + }, + { + accessorKey: 'kg_retak', + header: 'Retak (Kg)', + cell: (props) => formatNumber(props.row.original.kg_retak), + }, + { + accessorKey: 'kg_pecah', + header: 'Pecah (Kg)', + cell: (props) => formatNumber(props.row.original.kg_pecah), + }, + { + accessorKey: 'kg_jumlah', + header: 'Jumlah (Kg)', + cell: (props) => formatNumber(props.row.original.kg_jumlah), + }, + { + accessorKey: 'total_kg', + header: 'Total Kg', + cell: (props) => formatNumber(props.row.original.total_kg), + }, + ], + }, + { + header: '%', + columns: [ + { + accessorKey: 'persen_utuh', + header: 'Utuh', + cell: (props) => formatNumber(props.row.original.persen_utuh), + }, + { + accessorKey: 'persen_putih', + header: 'Putih', + cell: (props) => formatNumber(props.row.original.persen_putih), + }, + { + accessorKey: 'persen_retak', + header: 'Retak', + cell: (props) => formatNumber(props.row.original.persen_retak), + }, + { + accessorKey: 'persen_pecah', + header: 'Pecah', + cell: (props) => formatNumber(props.row.original.persen_pecah), + }, + ], + }, + ]; - const [isLoadingExportingToPdf, setIsLoadingExportingToPdf] = useState(false); - - const [selectedArea, setSelectedArea] = useState(null); - const [selectedLocation, setSelectedLocation] = useState( - null - ); - const [selectedProjectFlock, setSelectedProjectFlock] = - useState(null); - const [selectedProjectFlockKandang, setSelectedProjectFlockKandang] = - useState(null); + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + area_id: null, + location_id: null, + project_flock_id: null, + kandang_id: null, + }, + validationSchema: ProductionResultFilterSchema, + onSubmit: (values) => { + setFilterParams({ + area_id: values.area_id?.value + ? String(values.area_id.value) + : undefined, + location_id: values.location_id?.value + ? String(values.location_id.value) + : undefined, + project_flock_id: values.project_flock_id?.value + ? String(values.project_flock_id.value) + : undefined, + project_flock_kandang_id: values.kandang_id?.value + ? String(values.kandang_id.value) + : undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setPage(1); + }, + onReset: () => { + setFilterParams({}); + setIsSubmitted(false); + setPage(1); + }, + }); + // ===== OPTIONS ===== const { setInputValue: setAreaInputValue, options: areaOptions, - isLoadingOptions: isLoadingAreaOptions, + isLoadingOptions: isLoadingAreas, loadMore: loadMoreAreas, - } = useSelect(AreaApi.basePath, 'id', 'name'); - - const areaChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedArea(val as OptionType); - - setSelectedLocation(null); - - setSelectedProjectFlock(null); - - setSelectedProjectFlockKandang(null); - }; + } = useSelect(AreaApi.basePath, 'id', 'name', 'search'); const { setInputValue: setLocationInputValue, options: locationOptions, - isLoadingOptions: isLoadingLocationOptions, + isLoadingOptions: isLoadingLocations, loadMore: loadMoreLocations, } = useSelect(LocationApi.basePath, 'id', 'name', 'search', { - area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '', + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) + : '', }); - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedLocation(val as OptionType); - - setSelectedProjectFlock(null); - - setSelectedProjectFlockKandang(null); - }; - const { setInputValue: setProjectFlockInputValue, options: projectFlockOptions, - isLoadingOptions: isLoadingProjectFlockOptions, + isLoadingOptions: isLoadingProjectFlocks, loadMore: loadMoreProjectFlocks, } = useSelect( ProjectFlockApi.basePath, @@ -114,26 +277,20 @@ const ProductionResultContent = () => { 'flock_name', 'search', { - area_id: selectedArea - ? ((selectedArea as OptionType).value as string) + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) : '', - location_id: selectedLocation - ? ((selectedLocation as OptionType).value as string) + location_id: formik.values.location_id?.value + ? String(formik.values.location_id.value) : '', category: 'LAYING', } ); - const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedProjectFlock(val as OptionType); - - setSelectedProjectFlockKandang(null); - }; - const { setInputValue: setProjectFlockKandangInputValue, options: projectFlockKandangOptions, - isLoadingOptions: isLoadingProjectFlockKandangOptions, + isLoadingOptions: isLoadingProjectFlockKandangs, loadMore: loadMoreProjectFlockKandangs, } = useSelect( ProjectFlockKandangApi.basePath, @@ -141,37 +298,103 @@ const ProductionResultContent = () => { 'kandang.name', 'search', { - area_id: selectedArea - ? ((selectedArea as OptionType).value as string) + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) : '', - location_id: selectedLocation - ? ((selectedLocation as OptionType).value as string) + location_id: formik.values.location_id?.value + ? String(formik.values.location_id.value) : '', - project_flock_id: selectedProjectFlock - ? ((selectedProjectFlock as OptionType).value as string) + project_flock_id: formik.values.project_flock_id?.value + ? String(formik.values.project_flock_id.value) : '', } ); - const projectFlockKandangChangeHandler = ( - val: OptionType | OptionType[] | null - ) => { - setSelectedProjectFlockKandang(val as OptionType); - }; + // ===== FILTER HELPERS ===== + const areaValue = useMemo( + () => formik.values.area_id, + [formik.values.area_id] + ); - const exportToExcelHandler = async () => { - setIsLoadingExportingToExcel(true); + const locationValue = useMemo( + () => formik.values.location_id, + [formik.values.location_id] + ); + + const projectFlockValue = useMemo( + () => formik.values.project_flock_id, + [formik.values.project_flock_id] + ); + + const projectFlockKandangValue = useMemo( + () => formik.values.kandang_id, + [formik.values.kandang_id] + ); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + if (filterParams.area_id) count += 1; + if (filterParams.location_id) count += 1; + if (filterParams.project_flock_id) count += 1; + if (filterParams.project_flock_kandang_id) count += 1; + + return count; + }, [filterParams]); + + const hasFilters = activeFiltersCount > 0; + + // ===== DATA FETCHING ===== + const { data: projectFlockKandangsData, isLoading } = useSWR< + BaseApiResponse + >( + isSubmitted + ? () => { + const params = new URLSearchParams(); + if (filterParams.area_id) + params.append('area_id', filterParams.area_id); + if (filterParams.project_flock_id) + params.append('project_flock_id', filterParams.project_flock_id); + params.append('page', String(page)); + params.append('limit', String(pageSize)); + + return [`/production/project-flock-kandangs?${params.toString()}`]; + } + : null, + ([url]: string[]) => httpClient>(url) + ); + + const projectFlockKandangs = useMemo( + () => + isResponseSuccess(projectFlockKandangsData) + ? projectFlockKandangsData.data + : null, + [projectFlockKandangsData] + ); + + const projectFlockKandangMetadata = useMemo( + () => + isResponseSuccess(projectFlockKandangsData) + ? projectFlockKandangsData.meta + : undefined, + [projectFlockKandangsData] + ); + + // ===== EXPORT HANDLERS ===== + const exportToExcelHandler = useCallback(async () => { + setIsExcelExportLoading(true); try { - let projectFlockKandangsData: BaseProjectFlockKandang[] = []; + let projectFlockKandangsFetch: BaseProjectFlockKandang[] = []; - if (selectedProjectFlockKandang) { + if (filterParams.project_flock_kandang_id) { const projectFlockKandangResponse = await ProjectFlockKandangApi.getSingle( - selectedProjectFlockKandang?.value as number + Number(filterParams.project_flock_kandang_id) ); - projectFlockKandangsData = isResponseSuccess( + projectFlockKandangsFetch = isResponseSuccess( projectFlockKandangResponse ) ? [projectFlockKandangResponse.data] @@ -179,11 +402,11 @@ const ProductionResultContent = () => { } else { const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({ - area_id: selectedArea?.value, - project_flock_id: selectedProjectFlock?.value, + area_id: filterParams.area_id, + project_flock_id: filterParams.project_flock_id, }); - projectFlockKandangsData = isResponseSuccess( + projectFlockKandangsFetch = isResponseSuccess( projectFlockKandangsResponse ) ? projectFlockKandangsResponse.data @@ -192,7 +415,7 @@ const ProductionResultContent = () => { const productionResultData: ProductionResult[] = []; - for (const kandang of projectFlockKandangsData) { + for (const kandang of projectFlockKandangsFetch) { const getProductionResultPath = `${ProductionResultReportApi.basePath}/${kandang.id}?page=1&limit=100`; const getProductionResultRes = await httpClient< BaseApiResponse @@ -205,7 +428,7 @@ const ProductionResultContent = () => { project_flock: { ...result.project_flock, name: - selectedProjectFlock?.label || + projectFlockValue?.label || kandang.project_flock?.name || `Project Flock #${kandang.project_flock_id}`, category: kandang.project_flock?.category || '', @@ -222,7 +445,7 @@ const ProductionResultContent = () => { if (productionResultData.length === 0) { toast.error('Tidak ada data untuk diexport.'); - setIsLoadingExportingToExcel(false); + setIsExcelExportLoading(false); return; } @@ -233,23 +456,23 @@ const ProductionResultContent = () => { } catch { toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); } finally { - setIsLoadingExportingToExcel(false); + setIsExcelExportLoading(false); } - }; + }, [filterParams, projectFlockValue]); - const exportToPdfHandler = async () => { - setIsLoadingExportingToPdf(true); + const exportToPdfHandler = useCallback(async () => { + setIsPdfExportLoading(true); try { - let projectFlockKandangsData: BaseProjectFlockKandang[] = []; + let projectFlockKandangsFetch: BaseProjectFlockKandang[] = []; - if (selectedProjectFlockKandang) { + if (filterParams.project_flock_kandang_id) { const projectFlockKandangResponse = await ProjectFlockKandangApi.getSingle( - selectedProjectFlockKandang?.value as number + Number(filterParams.project_flock_kandang_id) ); - projectFlockKandangsData = isResponseSuccess( + projectFlockKandangsFetch = isResponseSuccess( projectFlockKandangResponse ) ? [projectFlockKandangResponse.data] @@ -257,11 +480,11 @@ const ProductionResultContent = () => { } else { const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({ - area_id: selectedArea?.value, - project_flock_id: selectedProjectFlock?.value, + area_id: filterParams.area_id, + project_flock_id: filterParams.project_flock_id, }); - projectFlockKandangsData = isResponseSuccess( + projectFlockKandangsFetch = isResponseSuccess( projectFlockKandangsResponse ) ? projectFlockKandangsResponse.data @@ -272,7 +495,7 @@ const ProductionResultContent = () => { projectFlockKandang: BaseProjectFlockKandang; productionResult: ProductionResult[] | null; }[] = await Promise.all( - projectFlockKandangsData.map(async (projectFlockKandang) => { + projectFlockKandangsFetch.map(async (projectFlockKandang) => { const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`; const getProductionResultRes = await httpClient< BaseApiResponse @@ -289,7 +512,7 @@ const ProductionResultContent = () => { if (mappedProductionResults.length === 0) { toast.error('Tidak ada data untuk diexport.'); - setIsLoadingExportingToPdf(false); + setIsPdfExportLoading(false); return; } @@ -312,258 +535,141 @@ const ProductionResultContent = () => { toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); } - setIsLoadingExportingToPdf(false); - }; + setIsPdfExportLoading(false); + }, [filterParams]); - const searchHandler = async () => { - setProjectFlockKandangs(null); - setIsLoadingSearch(true); + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); - try { - if (selectedProjectFlockKandang) { - const projectFlockKandangResponse = - await ProjectFlockKandangApi.getSingle( - selectedProjectFlockKandang?.value as number - ); + useEffect(() => { + setTabActions( + tabId, +
+ - if ( - !projectFlockKandangResponse || - isResponseError(projectFlockKandangResponse) - ) { - throw new Error(); - } + + + Export +
+ +
+ + } + align='end' + className={{ + content: + 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', + }} + > + + + + +
+
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + exportToExcelHandler, + exportToPdfHandler, + setTabActions, + ]); - setProjectFlockKandangs([projectFlockKandangResponse.data]); - setProjectFlockKandangMetadata({ - page: 1, - limit: 10, - total_pages: 1, - total_results: 1, - }); - setIsLoadingSearch(false); - return; - } - - const projectFlockKandangsResponse = await ProjectFlockKandangApi.getAll({ - area_id: selectedArea?.value, - project_flock_id: selectedProjectFlock?.value, - }); - - if ( - !projectFlockKandangsResponse || - isResponseError(projectFlockKandangsResponse) - ) { - throw new Error(); - } - - setProjectFlockKandangs(projectFlockKandangsResponse.data); - setProjectFlockKandangMetadata(projectFlockKandangsResponse.meta); - setIsLoadingSearch(false); - } catch (error) { - toast.error('Gagal mencari data! Coba lagi.'); - setProjectFlockKandangs(null); - setProjectFlockKandangMetadata(undefined); - setIsLoadingSearch(false); - } - }; - - const resetHandler = () => { - setProjectFlockKandangs(null); - setSelectedArea(null); - setSelectedLocation(null); - setSelectedProjectFlock(null); - setSelectedProjectFlockKandang(null); - // resetFilter(); - }; + useEffect(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); return ( -
- -
-

- Laporan Hasil Produksi -

-
- - {/* Filters */} -
-
- - - - - - - -
- -
-
- - - - - Export{' '} - - - } - > - - - - - -
-
-
-
- -
- {isLoadingSearch && ( - - )} - - {!isLoadingSearch && !projectFlockKandangs && ( -

- Silakan pilih filter dan klik tombol Cari untuk menampilkan data. -

- )} - - {!isLoadingSearch && projectFlockKandangs?.length === 0 && ( -

- Tidak ada data kandang project flock yang dapat ditampilkan. -

- )} - - {!isLoadingSearch && projectFlockKandangs && ( - - {projectFlockKandangs.map((projectFlockKandang) => ( - +
+ {!isSubmitted ? ( + - ))} + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> + ) : isLoading ? ( +
+ +
+ ) : !projectFlockKandangs || projectFlockKandangs.length === 0 ? ( + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + ) : ( + <> + {projectFlockKandangs.map( + (projectFlockKandang: ProjectFlockKandang) => ( + + ) + )} -
+
{ onRowChange={setPageSize} />
- + )}
-
+ + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+ +
+ {/* Modal Body */} +
+ { + formik.setFieldValue('area_id', val); + formik.setFieldValue('location_id', null); + formik.setFieldValue('project_flock_id', null); + formik.setFieldValue('kandang_id', null); + }} + onInputChange={setAreaInputValue} + onMenuScrollToBottom={loadMoreAreas} + isClearable + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('location_id', val); + formik.setFieldValue('project_flock_id', null); + formik.setFieldValue('kandang_id', null); + }} + onInputChange={setLocationInputValue} + onMenuScrollToBottom={loadMoreLocations} + isClearable + isDisabled={!formik.values.area_id} + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('project_flock_id', val); + formik.setFieldValue('kandang_id', null); + }} + onInputChange={setProjectFlockInputValue} + onMenuScrollToBottom={loadMoreProjectFlocks} + isClearable + isDisabled={!formik.values.location_id} + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('kandang_id', val); + }} + onInputChange={setProjectFlockKandangInputValue} + onMenuScrollToBottom={loadMoreProjectFlockKandangs} + isClearable + isDisabled={!formik.values.project_flock_id} + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ ); }; From d5962f94a1ff0be63114e5d5721dd6fecdef826a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:29:37 +0700 Subject: [PATCH 29/36] refactor(FE): Refactor production result filter to use OptionType --- .../filter/ProductionResultFilter.ts | 50 +++++++++++++++++-- .../tab/ProductionResultTab.tsx | 44 +++++++++------- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts index 3281bb19..6df3759e 100644 --- a/src/components/pages/report/production-result/filter/ProductionResultFilter.ts +++ b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts @@ -1,3 +1,4 @@ +import { OptionType } from '@/components/input/SelectInput'; import * as yup from 'yup'; export type ProductionResultFilterProps = { @@ -7,12 +8,51 @@ export type ProductionResultFilterProps = { kandang_id: string | null; }; +export type ProductionResultFilterFormType = { + area_id: OptionType | null; + location_id: OptionType | null; + project_flock_id: OptionType | null; + kandang_id: OptionType | null; +}; + export const ProductionResultFilterSchema = yup.object({ - area_id: yup.string().required('Area wajib dipilih'), - location_id: yup.string().required('Lokasi wajib dipilih'), - project_flock_id: yup.string().required('Project Flock wajib dipilih'), - kandang_id: yup.string().required('Kandang wajib dipilih'), -}); + area_id: yup + .mixed() + .required('Area wajib dipilih') + .test('is-not-empty', 'Area wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return !!value; + }), + location_id: yup + .mixed() + .required('Lokasi wajib dipilih') + .test('is-not-empty', 'Lokasi wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return !!value; + }), + project_flock_id: yup + .mixed() + .required('Project Flock wajib dipilih') + .test('is-not-empty', 'Project Flock wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return !!value; + }), + kandang_id: yup + .mixed() + .required('Kandang wajib dipilih') + .test('is-not-empty', 'Kandang wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return !!value; + }), +}) as yup.ObjectSchema; export type ProductionResultFilterValues = yup.InferType< typeof ProductionResultFilterSchema diff --git a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx index 2d7915e8..090ca781 100644 --- a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx @@ -8,15 +8,15 @@ import toast from 'react-hot-toast'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import Dropdown from '@/components/dropdown/Dropdown'; -import SelectInput, { - OptionType, - useSelect, -} from '@/components/input/SelectInput'; +import SelectInput, { useSelect } from '@/components/input/SelectInput'; import Menu from '@/components/menu/Menu'; import MenuItem from '@/components/menu/MenuItem'; import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable'; import { useFormik } from 'formik'; -import { ProductionResultFilterSchema } from '@/components/pages/report/production-result/filter/ProductionResultFilter'; +import { + ProductionResultFilterSchema, + type ProductionResultFilterValues, +} from '@/components/pages/report/production-result/filter/ProductionResultFilter'; import { BaseKandang } from '@/types/api/master-data/kandang'; import { AreaApi, LocationApi } from '@/services/api/master-data'; @@ -53,13 +53,6 @@ interface FilterParams { project_flock_kandang_id?: string; } -type ProductionResultFilterFormValues = { - area_id: OptionType | null; - location_id: OptionType | null; - project_flock_id: OptionType | null; - kandang_id: OptionType | null; -}; - const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); @@ -213,7 +206,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { ]; // ===== FORMIK SETUP ===== - const formik = useFormik({ + const formik = useFormik({ initialValues: { area_id: null, location_id: null, @@ -221,6 +214,8 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { kandang_id: null, }, validationSchema: ProductionResultFilterSchema, + validateOnBlur: true, + validateOnChange: true, onSubmit: (values) => { setFilterParams({ area_id: values.area_id?.value @@ -723,7 +718,7 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { {/* Modal Body */}
{ onInputChange={setAreaInputValue} onMenuScrollToBottom={loadMoreAreas} isClearable + isError={formik.touched.area_id && Boolean(formik.errors.area_id)} + errorMessage={formik.errors.area_id} className={{ wrapper: 'w-full' }} /> { onMenuScrollToBottom={loadMoreLocations} isClearable isDisabled={!formik.values.area_id} + isError={ + formik.touched.location_id && Boolean(formik.errors.location_id) + } + errorMessage={formik.errors.location_id} className={{ wrapper: 'w-full' }} /> { onMenuScrollToBottom={loadMoreProjectFlocks} isClearable isDisabled={!formik.values.location_id} + isError={ + formik.touched.project_flock_id && + Boolean(formik.errors.project_flock_id) + } + errorMessage={formik.errors.project_flock_id} className={{ wrapper: 'w-full' }} /> { onMenuScrollToBottom={loadMoreProjectFlockKandangs} isClearable isDisabled={!formik.values.project_flock_id} + isError={ + formik.touched.kandang_id && Boolean(formik.errors.kandang_id) + } + errorMessage={formik.errors.kandang_id} className={{ wrapper: 'w-full' }} />
From 684f67593fcc60c948e7bc91015a0ea42d39d15a Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:32:45 +0700 Subject: [PATCH 30/36] refactor(FE): Refactor SelectInput styles for improved readability --- src/components/input/SelectInput.tsx | 53 ++++++++++++---------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 38be09e4..ef959ea7 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -284,23 +284,22 @@ const SelectInput = (props: SelectInputProps) => { isDisabled && !readOnly, 'bg-transparent! cursor-not-allowed!': readOnly, 'cursor-pointer!': !readOnly && !isDisabled, - 'border-red-500! ring-2 ring-red-200': isError, + 'border-error!': isError, + 'ring-2 ring-error/20': isError, 'border-indigo-500 ring-2 ring-indigo-200': - isFocused && !startAdornment, + isFocused && !startAdornment && !isError, 'border-base-content/10!': !isError && !isFocused, 'rounded-l-none!': inputPrefix && !startAdornment, 'rounded-r-none!': inputSuffix && !startAdornment, }), valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'), placeholder: () => - cn({ - 'text-gray-400 text-sm leading-tight': !isError, - 'text-red-300!': isError, + cn('text-gray-400 text-sm leading-tight', { + 'text-error!': isError, }), singleValue: () => - cn({ - 'm-0! text-gray-900 text-sm leading-tight': !isError, - 'text-error!': isError, + cn('m-0! text-gray-900 text-sm leading-tight', { + 'text-error!': isError && !readOnly, 'text-gray-900!': readOnly, }), input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'), @@ -404,32 +403,26 @@ const SelectInput = (props: SelectInputProps) => { className={cn('w-full', className?.select)} classNames={{ control: ({ isFocused, isDisabled }) => - cn( - 'w-full border transition-shadow', - // Gunakan rounded-lg untuk semua kasus - 'rounded-lg!', - { - 'bg-base-100!': !isDisabled && !readOnly, - 'bg-base-200! text-gray-400 cursor-not-allowed': - isDisabled && !readOnly, - 'bg-transparent! cursor-not-allowed!': readOnly, - 'cursor-pointer!': !readOnly && !isDisabled, - 'border-red-500! ring-2 ring-red-200': isError, - 'border-indigo-500 ring-2 ring-indigo-200': - isFocused && !startAdornment, - 'border-base-content/10!': !isError && !isFocused, - } - ), + cn('w-full border transition-shadow rounded-lg!', { + 'bg-base-100!': !isDisabled && !readOnly, + 'bg-base-200! text-gray-400 cursor-not-allowed': + isDisabled && !readOnly, + 'bg-transparent! cursor-not-allowed!': readOnly, + 'cursor-pointer!': !readOnly && !isDisabled, + 'border-error!': isError, + 'ring-2 ring-error/20': isError, + 'border-indigo-500 ring-2 ring-indigo-200': + isFocused && !startAdornment && !isError, + 'border-base-content/10!': !isError && !isFocused, + }), valueContainer: () => cn('flex-1 px-3! pr-2! py-2.5! gap-1'), placeholder: () => - cn({ - 'text-gray-400 text-sm leading-tight': !isError, - 'text-red-300!': isError, + cn('text-gray-400 text-sm leading-tight', { + 'text-error!': isError, }), singleValue: () => - cn({ - 'm-0! text-gray-900 text-sm leading-tight': !isError, - 'text-error!': isError, + cn('m-0! text-gray-900 text-sm leading-tight', { + 'text-error!': isError && !readOnly, 'text-gray-900!': readOnly, }), input: () => cn('text-gray-900 m-0! p-0! text-sm leading-tight'), From 3a676723e41afb8c92fb091c90b5553b352156d7 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:42:04 +0700 Subject: [PATCH 31/36] refactor(FE): Refactor table class names in DailyMarketingTab --- .../marketing/tab/DailyMarketingTab.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx index df06c83c..a336b671 100644 --- a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -725,10 +725,21 @@ const DailyMarketingTab = ({ tabId }: DailyMarketingTabProps) => { columns={getTableColumns()} renderFooter={data.length > 0} className={{ - containerClassName: cn('p-3', { - 'w-full mb-20': data.length === 0, - }), - headerColumnClassName: 'text-nowrap', + containerClassName: 'w-full mb-0!', + tableWrapperClassName: 'overflow-x-auto', + tableClassName: 'w-full table-auto text-sm', + headerRowClassName: 'border-b border-b-gray-200 bg-gray-50', + headerColumnClassName: + 'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200 text-nowrap', + bodyRowClassName: + 'hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', + tableFooterClassName: + 'bg-gray-100 font-semibold border border-gray-200', + footerRowClassName: 'border-t-2 border-gray-300', + footerColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', }} /> )} From d312da4c6639beb41fb866eea56a9be29988b9f9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:53:33 +0700 Subject: [PATCH 32/36] refactor(FE): Refactor dropdown and export button components --- .../report/finance/tab/CustomerPaymentTab.tsx | 77 +++++++++++-------- .../report/finance/tab/DebtSupplierTab.tsx | 69 ++++++++++------- .../tab/PurchasesPerSupplierTab.tsx | 77 +++++++++++-------- .../report/marketing/tab/HppPerKandangTab.tsx | 77 +++++++++++-------- .../tab/ProductionResultTab.tsx | 77 +++++++++++-------- 5 files changed, 216 insertions(+), 161 deletions(-) diff --git a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx index 1443fa0b..1c546058 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -25,8 +25,6 @@ import { import { isResponseSuccess } from '@/lib/api-helper'; import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; -import MenuItem from '@/components/menu/MenuItem'; -import Menu from '@/components/menu/Menu'; import Modal, { useModal } from '@/components/Modal'; import toast from 'react-hot-toast'; import { useFormik } from 'formik'; @@ -387,13 +385,13 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { color='none' onClick={handleFilterModalOpen} className={cn( - 'px-3 py-2.5', - 'rounded-lg! font-semibold text-sm gap-1.5', - 'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft', - hasFilters && 'border-primary-gradient text-primary' + 'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all', + { + 'border-primary-gradient text-primary': hasFilters, + } )} > - + Filter {hasFilters && ( @@ -403,42 +401,55 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { - - Export -
- +
+ + + Export + +
+ +
} - align='end' - className={{ - content: - 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', - }} > - - - - + +
); diff --git a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx index 44677313..d0a27b92 100644 --- a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx +++ b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx @@ -3,8 +3,6 @@ import Card from '@/components/Card'; import Dropdown from '@/components/Dropdown'; import DateInput from '@/components/input/DateInput'; import { OptionType, useSelect } from '@/components/input/SelectInput'; -import Menu from '@/components/menu/Menu'; -import MenuItem from '@/components/menu/MenuItem'; import Modal, { useModal } from '@/components/Modal'; import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; import { isResponseSuccess } from '@/lib/api-helper'; @@ -277,7 +275,7 @@ const DebtSupplierTab = ({ tabId }: DebtSupplierTabProps) => { useEffect(() => { setTabActions( tabId, -
+
{ /> - - Export -
- +
+ + + Export + +
+ +
} - align='end' - className={{ - content: - 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', - }} > - - - - + +
); diff --git a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx index 5e2c4e27..36476956 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -3,8 +3,6 @@ import Card from '@/components/Card'; import Dropdown from '@/components/Dropdown'; import DateInput from '@/components/input/DateInput'; import { useSelect } from '@/components/input/SelectInput'; -import Menu from '@/components/menu/Menu'; -import MenuItem from '@/components/menu/MenuItem'; import Modal, { useModal } from '@/components/Modal'; import Table from '@/components/Table'; import { isResponseSuccess } from '@/lib/api-helper'; @@ -498,13 +496,13 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { color='none' onClick={handleFilterModalOpen} className={cn( - 'px-3 py-2.5', - 'rounded-lg! font-semibold text-sm gap-1.5', - 'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft', - hasFilters && 'border-primary-gradient text-primary' + 'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all', + { + 'border-primary-gradient text-primary': hasFilters, + } )} > - + Filter {hasFilters && ( @@ -514,42 +512,55 @@ const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { - - Export -
- +
+ + + Export + +
+ +
} - align='end' - className={{ - content: - 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', - }} > - - - - + +
); diff --git a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx index d3d4bfac..e106dbf4 100644 --- a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx +++ b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx @@ -18,8 +18,6 @@ import { import { isResponseSuccess } from '@/lib/api-helper'; import Button from '@/components/Button'; import Dropdown from '@/components/Dropdown'; -import MenuItem from '@/components/menu/MenuItem'; -import Menu from '@/components/menu/Menu'; import { generateHppPerKandangPDF } from '@/components/pages/report/marketing/export/HppPerkandangExportPDF'; import { generateHppPerKandangExcel } from '@/components/pages/report/marketing/export/HppPerkandangExportXLSX'; import toast from 'react-hot-toast'; @@ -499,13 +497,13 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => { color='none' onClick={handleFilterModalOpen} className={cn( - 'px-3 py-2.5', - 'rounded-lg! font-semibold text-sm gap-1.5', - 'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft', - hasFilters && 'border-primary-gradient text-primary' + 'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all', + { + 'border-primary-gradient text-primary': hasFilters, + } )} > - + Filter {hasFilters && ( @@ -515,42 +513,55 @@ const HppPerKandangTab = ({ tabId }: HppPerKandangTabProps) => { - - Export -
- +
+ + + Export + +
+ +
} - align='end' - className={{ - content: - 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', - }} > - - - - + +
); diff --git a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx index 090ca781..d04c9e20 100644 --- a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultTab.tsx @@ -9,8 +9,6 @@ import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import Dropdown from '@/components/dropdown/Dropdown'; import SelectInput, { useSelect } from '@/components/input/SelectInput'; -import Menu from '@/components/menu/Menu'; -import MenuItem from '@/components/menu/MenuItem'; import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable'; import { useFormik } from 'formik'; import { @@ -546,13 +544,13 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { color='none' onClick={() => filterModal.openModal()} className={cn( - 'px-3 py-2.5', - 'rounded-lg! font-semibold text-sm gap-1.5', - 'text-sm text-base-content/50 border border-base-content/10 shadow-button-soft', - hasFilters && 'border-primary-gradient text-primary' + 'px-3 py-2.5 gap-1.5 text-sm text-base-content/50 border border-base-content/10 rounded-xl shadow-button-soft transition-all', + { + 'border-primary-gradient text-primary': hasFilters, + } )} > - + Filter {hasFilters && ( @@ -562,42 +560,55 @@ const ProductionResultContent = ({ tabId }: ProductionResultTabProps) => { - - Export -
- +
+ + + Export + +
+ +
} - align='end' - className={{ - content: - 'mt-1 p-0 w-full shadow-button-soft border border-base-content/10 rounded-lg', - }} > - - - - + +
); From ceb594a4ccd871bb9214615faf74561a9a67a7f9 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 09:55:22 +0700 Subject: [PATCH 33/36] refactor(FE): Rename and update paths for ProductionResult components --- .../pages/report/production-result/ProductionResultTabs.tsx | 2 +- ...ResultTab.tsx => ProductionResultProjectFlockKandangTab.tsx} | 2 +- .../{ => tab}/ProductionResultProjectFlockKandangTable.tsx | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/components/pages/report/production-result/tab/{ProductionResultTab.tsx => ProductionResultProjectFlockKandangTab.tsx} (99%) rename src/components/pages/report/production-result/{ => tab}/ProductionResultProjectFlockKandangTable.tsx (100%) diff --git a/src/components/pages/report/production-result/ProductionResultTabs.tsx b/src/components/pages/report/production-result/ProductionResultTabs.tsx index 2bd7765f..6f5e4410 100644 --- a/src/components/pages/report/production-result/ProductionResultTabs.tsx +++ b/src/components/pages/report/production-result/ProductionResultTabs.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; -import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultTab'; +import ProductionResultTab from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab'; import { useReportTabStore } from '@/stores/report/report-tab.store'; const ProductionResultTabs = () => { diff --git a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx similarity index 99% rename from src/components/pages/report/production-result/tab/ProductionResultTab.tsx rename to src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx index d04c9e20..9ac5faf6 100644 --- a/src/components/pages/report/production-result/tab/ProductionResultTab.tsx +++ b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx @@ -9,7 +9,7 @@ import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import Dropdown from '@/components/dropdown/Dropdown'; import SelectInput, { useSelect } from '@/components/input/SelectInput'; -import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/ProductionResultProjectFlockKandangTable'; +import ProductionResultProjectFlockKandangTable from '@/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable'; import { useFormik } from 'formik'; import { ProductionResultFilterSchema, diff --git a/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable.tsx similarity index 100% rename from src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx rename to src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable.tsx From 67f2a80f237df64d4c3c51b8eb251ddef7da9fe0 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 10:19:09 +0700 Subject: [PATCH 34/36] refactor(FE): Refactor expense report page to use tab-based layout --- src/app/report/expense/page.tsx | 8 +- .../report/expense/ReportExpenseTable.tsx | 901 ------------------ .../report/expense/ReportExpenseTabs.tsx | 40 + .../ReportExpenseExportPDF.tsx} | 1 + .../export/ReportExpenseExportXLSX.tsx | 109 +++ .../expense/filter/ReportExpenseFilter.ts | 73 ++ .../pdf/styles/ReportExpenseStyles.tsx | 365 ------- .../skeleton/ReportExpenseSkeleton.tsx | 51 + .../report/expense/tab/ReportExpenseTab.tsx | 755 +++++++++++++++ 9 files changed, 1031 insertions(+), 1272 deletions(-) delete mode 100644 src/components/pages/report/expense/ReportExpenseTable.tsx create mode 100644 src/components/pages/report/expense/ReportExpenseTabs.tsx rename src/components/pages/report/expense/{pdf/ReportExpenseExport.tsx => export/ReportExpenseExportPDF.tsx} (99%) create mode 100644 src/components/pages/report/expense/export/ReportExpenseExportXLSX.tsx create mode 100644 src/components/pages/report/expense/filter/ReportExpenseFilter.ts delete mode 100644 src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx create mode 100644 src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx create mode 100644 src/components/pages/report/expense/tab/ReportExpenseTab.tsx diff --git a/src/app/report/expense/page.tsx b/src/app/report/expense/page.tsx index 99d2862e..bb497283 100644 --- a/src/app/report/expense/page.tsx +++ b/src/app/report/expense/page.tsx @@ -1,13 +1,9 @@ 'use client'; -import ReportExpenseTable from '@/components/pages/report/expense/ReportExpenseTable'; +import ReportExpenseTabs from '@/components/pages/report/expense/ReportExpenseTabs'; const ReportExpense = () => { - return ( -
- -
- ); + return ; }; export default ReportExpense; diff --git a/src/components/pages/report/expense/ReportExpenseTable.tsx b/src/components/pages/report/expense/ReportExpenseTable.tsx deleted file mode 100644 index c809c153..00000000 --- a/src/components/pages/report/expense/ReportExpenseTable.tsx +++ /dev/null @@ -1,901 +0,0 @@ -import { useState, useMemo, useCallback } from 'react'; -import { ChangeEventHandler } from 'react'; -import useSWR from 'swr'; -import Button from '@/components/Button'; -import Card from '@/components/Card'; -import DateInput from '@/components/input/DateInput'; -import DebouncedTextInput from '@/components/input/DebouncedTextInput'; -import SelectInput, { - OptionType, - useSelect, -} from '@/components/input/SelectInput'; -import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; -import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; -import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; -import { cn, formatCurrency, formatDate } from '@/lib/helper'; -import { ReportExpense } from '@/types/api/report/report-expense'; -import { Icon } from '@iconify/react'; -import { ColumnDef } from '@tanstack/react-table'; -import { ReportExpenseApi } from '@/services/api/report'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { useTableFilter } from '@/services/hooks/useTableFilter'; -import Pagination from '@/components/Pagination'; -import Dropdown from '@/components/dropdown/Dropdown'; -import Menu from '@/components/menu/Menu'; -import MenuItem from '@/components/menu/MenuItem'; -import * as XLSX from 'xlsx'; -import { generateReportExpensePDF } from './pdf/ReportExpenseExport'; -import toast from 'react-hot-toast'; -import { - KandangApi, - LocationApi, - NonstockApi, - SupplierApi, -} from '@/services/api/master-data'; -import { Supplier } from '@/types/api/master-data/supplier'; -import { Kandang } from '@/types/api/master-data/kandang'; -import { Nonstock } from '@/types/api/master-data/nonstock'; - -const ReportExpenseTable = () => { - // ===== STATE MANAGEMENT ===== - const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); - const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [pdfProgress, setPdfProgress] = useState(0); - const [excelProgress, setExcelProgress] = useState(0); - const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; - - // ===== SUBMISSION STATE ===== - const [isSubmitted, setIsSubmitted] = useState(false); - - // ===== TABLE FILTER STATE ===== - const { - state: filterState, - updateFilter, - setPage, - setPageSize, - reset: resetFilterState, - toQueryString, - } = useTableFilter({ - initial: { - location_id: '', - supplier_id: '', - kandang_id: '', - nonstock_id: '', - realization_date: '', - category: '', - search: '', - }, - paramMap: { - page: 'page', - pageSize: 'limit', - }, - }); - - // ===== SELECT OPTIONS ===== - const { - setInputValue: setLocationInputValue, - options: locationOptions, - isLoadingOptions: isLoadingLocationOptions, - loadMore: loadMoreLocations, - } = useSelect(LocationApi.basePath, 'id', 'name'); - - const { - setInputValue: setSupplierInputValue, - options: supplierOptions, - isLoadingOptions: isLoadingSupplierOptions, - loadMore: loadMoreSuppliers, - } = useSelect(SupplierApi.basePath, 'id', 'name'); - - const { - setInputValue: setKandangInputValue, - options: kandangOptions, - isLoadingOptions: isLoadingKandangOptions, - loadMore: loadMoreKandangs, - } = useSelect(KandangApi.basePath, 'id', 'name'); - - const { - setInputValue: setNonstockInputValue, - options: nonstockOptions, - isLoadingOptions: isLoadingNonstockOptions, - loadMore: loadMoreNonstocks, - } = useSelect(NonstockApi.basePath, 'id', 'name'); - - const categoryOptions = useMemo( - () => [ - { value: 'BOP', label: 'BOP' }, - { value: 'NON-BOP', label: 'Non BOP' }, - ], - [] - ); - - // Mendapatkan value option select dari filter state - const selectedLocation = useMemo( - () => - locationOptions.find( - (opt) => String(opt.value) === filterState.location_id - ) || null, - [locationOptions, filterState.location_id] - ); - const selectedSupplier = useMemo( - () => - supplierOptions.find( - (opt) => String(opt.value) === filterState.supplier_id - ) || null, - [supplierOptions, filterState.supplier_id] - ); - const selectedKandang = useMemo( - () => - kandangOptions.find( - (opt) => String(opt.value) === filterState.kandang_id - ) || null, - [kandangOptions, filterState.kandang_id] - ); - const selectedNonstock = useMemo( - () => - nonstockOptions.find( - (opt) => String(opt.value) === filterState.nonstock_id - ) || null, - [nonstockOptions, filterState.nonstock_id] - ); - const selectedCategory = useMemo( - () => - categoryOptions.find((opt) => opt.value === filterState.category) || null, - [categoryOptions, filterState.category] - ); - - // ===== FILTER CHANGE HANDLERS ===== - const locationChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType; - updateFilter('location_id', option ? String(option.value) : ''); - updateFilter('kandang_id', ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const kandangChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType; - updateFilter('kandang_id', option ? String(option.value) : ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const supplierChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType; - updateFilter('supplier_id', option ? String(option.value) : ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const nonstockChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType; - updateFilter('nonstock_id', option ? String(option.value) : ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const categoryChangeHandler = useCallback( - (val: OptionType | OptionType[] | null) => { - const option = val as OptionType; - updateFilter('category', option ? String(option.value) : ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const realizationDateChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - updateFilter('realization_date', e.target.value || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); - - const searchChangeHandler = useCallback( - (e: React.ChangeEvent) => { - updateFilter('search', e.target.value); - setIsSubmitted(false); - }, - [updateFilter] - ); - - // ===== RESET FILTERS ===== - const resetFilters = useCallback(() => { - resetFilterState(); - setIsSubmitted(false); - }, [resetFilterState]); - - // ===== SUBMIT HANDLER ===== - const handleSubmit = useCallback(() => { - setIsSubmitted(true); - setPage(1); - }, [setPage]); - - // ===== DATA FETCHING FOR TABLE ===== - const { data: reportExpenseResponse, isLoading } = useSWR( - isSubmitted - ? () => { - return ['report-expense', toQueryString()]; - } - : null, - ([, query]) => { - const endpoint = `${ReportExpenseApi.basePath}${query}`; - return ReportExpenseApi.getAllFetcher(endpoint); - } - ); - - const data: ReportExpense[] = useMemo( - () => - isResponseSuccess(reportExpenseResponse) - ? (reportExpenseResponse?.data as ReportExpense[]) || [] - : [], - [reportExpenseResponse] - ); - - const meta = useMemo( - () => - isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta - ? reportExpenseResponse.meta - : null, - [reportExpenseResponse] - ); - - // ===== EXPORT DATA FETCHER ===== - const reportExpenseExport = useCallback(async (): Promise< - ReportExpense[] | null - > => { - const params = new URLSearchParams(toQueryString().replace('?', '')); - params.set('limit', 'limit'); - params.set('page', '1'); - - const endpoint = `${ReportExpenseApi.basePath}?${params.toString()}`; - const response = await ReportExpenseApi.getAllFetcher(endpoint); - - return isResponseSuccess(response) ? response.data : null; - }, [toQueryString]); - - // ===== EXPORT HANDLERS ===== - const handleExportPdf = useCallback(async () => { - if (isPdfExportLoading) return; - setIsPdfExportLoading(true); - setPdfProgress(0); - - await new Promise((resolve) => - requestAnimationFrame(() => resolve(undefined)) - ); - - try { - // Stage 1: Fetching data (0-20%) - setPdfProgress(10); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const allData = await reportExpenseExport(); - if (!allData || allData.length === 0) { - toast.error('Tidak ada data untuk diekspor.'); - setIsPdfExportLoading(false); - setPdfProgress(0); - return; - } - - // Stage 2: Data fetched - langsung loncat ke progress tinggi - setPdfProgress(30); - await new Promise((resolve) => setTimeout(resolve, 50)); - const progressInterval = setInterval(() => { - setPdfProgress((prev) => { - // Increment kecil dan random antara 0.5-2% - const increment = Math.random() * 1.5 + 0.5; - const newProgress = Math.min(prev + increment, 50); - return newProgress; - }); - }, 300); // Update setiap 300ms - - const pdfParams = { - location_name: selectedLocation?.label, - supplier_name: selectedSupplier?.label, - kandang_name: selectedKandang?.label, - nonstock_name: selectedNonstock?.label, - category: selectedCategory?.label, - realization_date: filterState.realization_date, - search: filterState.search, - }; - - setDropdownOpen(false); - - // Stage 3: Langsung loncat ke 80-85% untuk menghindari stuck - const baseProgress = 80 + Math.floor(Math.random() * 16); // Random 80-85% - setPdfProgress(baseProgress); - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Stage 4: Berikan jeda untuk UI update - await new Promise((resolve) => - requestAnimationFrame(() => resolve(undefined)) - ); - - // Proses PDF yang sebenarnya - await generateReportExpensePDF(allData, pdfParams); - - clearInterval(progressInterval); - - // Stage 5: Finalizing (98-100%) - setPdfProgress(99); - await new Promise((resolve) => setTimeout(resolve, 100)); - - setPdfProgress(100); - toast.success('PDF berhasil dibuat dan diunduh.'); - - // Reset progress setelah selesai - setTimeout(() => setPdfProgress(0), 500); - } catch (error) { - console.error('PDF Export Error:', error); - toast.error('Gagal membuat PDF. Silakan coba lagi.'); - setPdfProgress(0); - } finally { - setIsPdfExportLoading(false); - } - }, [ - reportExpenseExport, - selectedLocation, - selectedSupplier, - selectedKandang, - selectedNonstock, - selectedCategory, - filterState.realization_date, - filterState.search, - ]); - - const handleExportExcel = useCallback(async () => { - if (isExcelExportLoading) return; - setIsExcelExportLoading(true); - setExcelProgress(0); - setDropdownOpen(false); - - await new Promise((resolve) => - requestAnimationFrame(() => resolve(undefined)) - ); - - try { - // Stage 1: Fetching data (0-20%) - setExcelProgress(15); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const allDataForExport = await reportExpenseExport(); - - if (!allDataForExport || allDataForExport.length === 0) { - toast.error('Tidak ada data untuk diekspor.'); - setIsExcelExportLoading(false); - setExcelProgress(0); - return; - } - - // Stage 2: Data fetched (20-40%) - setExcelProgress(30); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Stage 3: Grouping data (40-60%) - setExcelProgress(50); - const groupedBySupplier: Record = {}; - allDataForExport.forEach((item) => { - const supplierName = item.supplier?.name || 'Unknown Supplier'; - if (!groupedBySupplier[supplierName]) { - groupedBySupplier[supplierName] = []; - } - groupedBySupplier[supplierName].push(item); - }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Stage 4: Creating workbook (60-80%) - setExcelProgress(70); - const workbook = XLSX.utils.book_new(); - - const supplierEntries = Object.entries(groupedBySupplier); - const totalSuppliers = supplierEntries.length; - - for (let i = 0; i < supplierEntries.length; i++) { - const [supplierName, supplierData] = supplierEntries[i]; - - // Update progress per supplier - const progressIncrement = (20 / totalSuppliers) * (i + 1); - setExcelProgress(70 + progressIncrement); - - const totals = supplierData.reduce( - (acc, item) => ({ - qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0), - total_pengajuan: - acc.total_pengajuan + - (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), - qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0), - total_realisasi: - acc.total_realisasi + - (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), - }), - { - qty_pengajuan: 0, - total_pengajuan: 0, - qty_realisasi: 0, - total_realisasi: 0, - } - ); - - const excelData = supplierData.map((item, index) => ({ - No: index + 1, - 'No. PO': item.po_number || '', - 'No. Referensi': item.reference_number || '', - 'Tanggal Realisasi': item.realization_date - ? formatDate(item.realization_date, 'DD MMM YYYY') - : '', - 'Tanggal Transaksi': item.transaction_date - ? formatDate(item.transaction_date, 'DD MMM YYYY') - : '', - Kategori: item.category || '', - Produk: item.pengajuan?.nonstock?.name || '', - Lokasi: item.kandang?.location?.name || '', - Kandang: item.kandang?.name || '', - 'Qty Pengajuan': item.pengajuan?.qty || 0, - 'Harga Pengajuan': item.pengajuan?.price || 0, - 'Total Pengajuan': - (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), - 'Qty Realisasi': item.realisasi?.qty || 0, - 'Harga Realisasi': item.realisasi?.price || 0, - 'Total Realisasi': - (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), - 'Status Pencairan': item.latest_approval?.step_name || '', - })); - - excelData.push({ - No: 'Total' as unknown as number, - 'No. PO': '', - 'No. Referensi': '', - 'Tanggal Realisasi': '', - 'Tanggal Transaksi': '', - Kategori: '', - Produk: '', - Lokasi: '', - Kandang: '', - 'Qty Pengajuan': totals.qty_pengajuan, - 'Harga Pengajuan': 0, - 'Total Pengajuan': totals.total_pengajuan, - 'Qty Realisasi': totals.qty_realisasi, - 'Harga Realisasi': 0, - 'Total Realisasi': totals.total_realisasi, - 'Status Pencairan': '', - }); - - const worksheet = XLSX.utils.json_to_sheet(excelData); - const colWidths = [ - { wch: 5 }, // No - { wch: 20 }, // No. PO - { wch: 20 }, // No. Referensi - { wch: 15 }, // Tanggal Realisasi - { wch: 15 }, // Tanggal Transaksi - { wch: 15 }, // Kategori - { wch: 30 }, // Produk - { wch: 20 }, // Lokasi - { wch: 15 }, // Kandang - { wch: 15 }, // Qty Pengajuan - { wch: 15 }, // Harga Pengajuan - { wch: 20 }, // Total Pengajuan - { wch: 15 }, // Qty Realisasi - { wch: 15 }, // Harga Realisasi - { wch: 20 }, // Total Realisasi - { wch: 20 }, // Status Pencairan - ]; - worksheet['!cols'] = colWidths; - - const sheetName = supplierName.slice(0, 31); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - - // Small delay to allow UI update - if (i < supplierEntries.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - // Stage 5: Writing file (90-100%) - setExcelProgress(95); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`; - XLSX.writeFile(workbook, filename); - - setExcelProgress(100); - toast.success('Excel berhasil dibuat dan diunduh.'); - - // Reset progress - setTimeout(() => setExcelProgress(0), 500); - } catch (error) { - console.error('Excel Export Error:', error); - toast.error('Gagal membuat Excel. Silakan coba lagi.'); - setExcelProgress(0); - } finally { - setIsExcelExportLoading(false); - } - }, [isExcelExportLoading, reportExpenseExport]); - - // ===== PAGINATION HANDLERS ===== - const handlePageChange = (page: number) => { - setPage(page); - }; - - const handleRowChange = (pageSize: number) => { - setPageSize(pageSize); - }; - - const handleNextPage = () => { - if (meta && filterState.page < meta.total_pages) { - setPage(filterState.page + 1); - } - }; - - const handlePrevPage = () => { - if (filterState.page > 1) { - setPage(filterState.page - 1); - } - }; - - // ===== TABLE COLUMNS DEFINITION ===== - const columns = useMemo((): ColumnDef[] => { - return [ - { - header: 'No', - accessorFn: (_, index) => - (filterState.page - 1) * filterState.pageSize + index + 1, - }, - { - header: 'No. PO', - accessorKey: 'po_number', - }, - { - header: 'No. Referensi', - accessorKey: 'reference_number', - }, - { - header: 'Tanggal Realisasi', - accessorKey: 'realization_date', - cell: ({ row }) => { - return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); - }, - }, - { - header: 'Tanggal Transaksi', - accessorKey: 'transaction_date', - cell: ({ row }) => { - return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); - }, - }, - { - header: 'Kategori', - accessorKey: 'category', - }, - { - header: 'Produk', - accessorFn: (row) => row.pengajuan?.nonstock?.name, - }, - { - header: 'Supplier', - accessorFn: (row) => row.supplier?.name, - }, - { - header: 'Lokasi', - accessorFn: (row) => row.kandang?.location?.name, - }, - { - header: 'Kandang', - accessorFn: (row) => row.kandang?.name, - }, - { - header: 'Pengajuan', - columns: [ - { - header: 'Qty', - id: 'qty_pengajuan', - accessorFn: (row) => row.pengajuan?.qty, - cell: ({ row }) => - row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', - }, - { - header: 'Harga', - id: 'harga_pengajuan', - accessorFn: (row) => row.pengajuan?.price, - cell: ({ row }) => - formatCurrency(row.original.pengajuan?.price || 0), - }, - { - header: 'Total', - id: 'total_pengajuan', - accessorFn: (row) => - (row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0), - cell: ({ row }) => { - const total = - (row.original.pengajuan?.qty || 0) * - (row.original.pengajuan?.price || 0); - return formatCurrency(total); - }, - }, - ], - }, - { - header: 'Realisasi', - columns: [ - { - header: 'Qty', - id: 'qty_realisasi', - accessorFn: (row) => row.realisasi?.qty, - cell: ({ row }) => - row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', - }, - { - header: 'Harga', - id: 'harga_realisasi', - accessorFn: (row) => row.realisasi?.price, - cell: ({ row }) => - formatCurrency(row.original.realisasi?.price || 0), - }, - { - header: 'Total', - id: 'total_realisasi', - accessorFn: (row) => - (row.realisasi?.qty || 0) * (row.realisasi?.price || 0), - cell: ({ row }) => { - const total = - (row.original.realisasi?.qty || 0) * - (row.original.realisasi?.price || 0); - return formatCurrency(total); - }, - }, - ], - }, - { - header: 'Status Pencairan', - cell: (props) => ( - - ), - }, - { - header: 'Status BOP', - cell: (props) => ( - - ), - }, - ]; - }, [filterState.page, filterState.pageSize]); - - // ===== RENDER ===== - return ( -
- {isAnyExportLoading && ( -
- - {((isPdfExportLoading && pdfProgress > 0) || - (isExcelExportLoading && excelProgress > 0)) && ( -
-
- {(() => { - const currentProgress = isPdfExportLoading - ? pdfProgress - : excelProgress; - const exportType = isPdfExportLoading ? 'PDF' : 'Excel'; - - if (currentProgress < 20) - return 'Mengambil data dari server...'; - if (currentProgress < 30) return 'Memproses data laporan...'; - if (currentProgress < 40) - return `Menyiapkan struktur dokumen ${exportType}...`; - if (currentProgress < 50) - return 'Mengelompokkan data per supplier...'; - if (currentProgress < 70) - return 'Merender tabel dan kalkulasi...'; - if (currentProgress < 96) - return `Memformat dokumen ${exportType}...`; - if (currentProgress < 100) - return 'Menyelesaikan dan mengunduh...'; - return 'Selesai!'; - })()}{' '} - {Math.round(isPdfExportLoading ? pdfProgress : excelProgress)}% -
- {((isPdfExportLoading && pdfProgress >= 35 && pdfProgress < 90) || - (isExcelExportLoading && - excelProgress >= 35 && - excelProgress < 90)) && ( -
- {(isPdfExportLoading ? pdfProgress : excelProgress) < 96 - ? 'Proses ini membutuhkan waktu lebih lama untuk data dalam jumlah besar. Mohon bersabar...' - : 'Sedang memproses baris data. Hampir selesai...'} -
- )} -
- )} -
- )} - -
-
- - -
-
- { - setDropdownOpen(!dropdownOpen); - }} - > - Export - - } - align='end' - direction='bottom' - open={dropdownOpen} - > - - - - - -
-
-
- } - > -
- - - - - - - } - /> -
- - - {/* ===== TABLE CONTENT ===== */} - {!isSubmitted ? ( -
- Silakan pilih filter dan klik tombol Cari untuk menampilkan data. -
- ) : isLoading ? ( -
- -
- ) : data.length === 0 ? ( -
- Tidak ada data yang dapat ditampilkan... -
- ) : ( - <> - - columns={columns} - data={data} - pageSize={10} - className={{ - containerClassName: 'mb-0', - headerRowClassName: cn( - TABLE_DEFAULT_STYLING, - 'whitespace-nowrap' - ), - bodyRowClassName: cn(TABLE_DEFAULT_STYLING, 'whitespace-nowrap'), - paginationClassName: 'hidden', - }} - /> - {meta && ( -
- -
- )} - - )} -
- ); -}; - -export default ReportExpenseTable; diff --git a/src/components/pages/report/expense/ReportExpenseTabs.tsx b/src/components/pages/report/expense/ReportExpenseTabs.tsx new file mode 100644 index 00000000..704d1f6f --- /dev/null +++ b/src/components/pages/report/expense/ReportExpenseTabs.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useState } from 'react'; +import Tabs from '@/components/Tabs'; + +import { useReportTabStore } from '@/stores/report/report-tab.store'; +import ReportExpenseTab from './tab/ReportExpenseTab'; + +const ReportExpenseTabs = () => { + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useReportTabStore((state) => state.tabActions); + + const tabs = [ + { + id: '1', + label: 'Laporan Biaya Operasional', + content: , + }, + ]; + + return ( +
+ +
+ ); +}; + +export default ReportExpenseTabs; diff --git a/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx b/src/components/pages/report/expense/export/ReportExpenseExportPDF.tsx similarity index 99% rename from src/components/pages/report/expense/pdf/ReportExpenseExport.tsx rename to src/components/pages/report/expense/export/ReportExpenseExportPDF.tsx index a7ff8599..6ec2c559 100644 --- a/src/components/pages/report/expense/pdf/ReportExpenseExport.tsx +++ b/src/components/pages/report/expense/export/ReportExpenseExportPDF.tsx @@ -2,6 +2,7 @@ import { ReportExpense } from '@/types/api/report/report-expense'; import { formatCurrency, formatDate } from '@/lib/helper'; import jsPDF from 'jspdf'; import autoTable, { UserOptions } from 'jspdf-autotable'; + interface jsPDFWithAutoTable extends jsPDF { lastAutoTable: { finalY: number; diff --git a/src/components/pages/report/expense/export/ReportExpenseExportXLSX.tsx b/src/components/pages/report/expense/export/ReportExpenseExportXLSX.tsx new file mode 100644 index 00000000..cc27b526 --- /dev/null +++ b/src/components/pages/report/expense/export/ReportExpenseExportXLSX.tsx @@ -0,0 +1,109 @@ +import * as XLSX from 'xlsx'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import { formatCurrency, formatDate } from '@/lib/helper'; + +export const generateReportExpenseExcel = async ( + data: ReportExpense[] +): Promise => { + // Group by supplier + const groupedBySupplier: Record = {}; + data.forEach((item) => { + const supplierName = item.supplier?.name || 'Unknown Supplier'; + if (!groupedBySupplier[supplierName]) { + groupedBySupplier[supplierName] = []; + } + groupedBySupplier[supplierName].push(item); + }); + + const workbook = XLSX.utils.book_new(); + + Object.entries(groupedBySupplier).forEach(([supplierName, supplierData]) => { + const totals = supplierData.reduce( + (acc, item) => ({ + qty_pengajuan: acc.qty_pengajuan + (item.pengajuan?.qty || 0), + total_pengajuan: + acc.total_pengajuan + + (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), + qty_realisasi: acc.qty_realisasi + (item.realisasi?.qty || 0), + total_realisasi: + acc.total_realisasi + + (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), + }), + { + qty_pengajuan: 0, + total_pengajuan: 0, + qty_realisasi: 0, + total_realisasi: 0, + } + ); + + const excelData = supplierData.map((item, index) => ({ + No: index + 1, + 'No. PO': item.po_number || '', + 'No. Referensi': item.reference_number || '', + 'Tanggal Realisasi': item.realization_date + ? formatDate(item.realization_date, 'DD MMM YYYY') + : '', + 'Tanggal Transaksi': item.transaction_date + ? formatDate(item.transaction_date, 'DD MMM YYYY') + : '', + Kategori: item.category || '', + Produk: item.pengajuan?.nonstock?.name || '', + Lokasi: item.kandang?.location?.name || '', + Kandang: item.kandang?.name || '', + 'Qty Pengajuan': item.pengajuan?.qty || 0, + 'Harga Pengajuan': item.pengajuan?.price || 0, + 'Total Pengajuan': + (item.pengajuan?.qty || 0) * (item.pengajuan?.price || 0), + 'Qty Realisasi': item.realisasi?.qty || 0, + 'Harga Realisasi': item.realisasi?.price || 0, + 'Total Realisasi': + (item.realisasi?.qty || 0) * (item.realisasi?.price || 0), + 'Status Pencairan': item.latest_approval?.step_name || '', + })); + + excelData.push({ + No: 'Total' as unknown as number, + 'No. PO': '', + 'No. Referensi': '', + 'Tanggal Realisasi': '', + 'Tanggal Transaksi': '', + Kategori: '', + Produk: '', + Lokasi: '', + Kandang: '', + 'Qty Pengajuan': totals.qty_pengajuan, + 'Harga Pengajuan': 0, + 'Total Pengajuan': totals.total_pengajuan, + 'Qty Realisasi': totals.qty_realisasi, + 'Harga Realisasi': 0, + 'Total Realisasi': totals.total_realisasi, + 'Status Pencairan': '', + }); + + const worksheet = XLSX.utils.json_to_sheet(excelData); + const colWidths = [ + { wch: 5 }, + { wch: 20 }, + { wch: 20 }, + { wch: 15 }, + { wch: 15 }, + { wch: 15 }, + { wch: 30 }, + { wch: 20 }, + { wch: 15 }, + { wch: 15 }, + { wch: 15 }, + { wch: 20 }, + { wch: 15 }, + { wch: 20 }, + ]; + worksheet['!cols'] = colWidths; + + const sheetName = supplierName.slice(0, 31); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + }); + + const filename = `Laporan-BOP-${formatDate(new Date(), 'YYYY-MM-DD-HHmm')}.xlsx`; + XLSX.writeFile(workbook, filename); +}; diff --git a/src/components/pages/report/expense/filter/ReportExpenseFilter.ts b/src/components/pages/report/expense/filter/ReportExpenseFilter.ts new file mode 100644 index 00000000..b8bd3c56 --- /dev/null +++ b/src/components/pages/report/expense/filter/ReportExpenseFilter.ts @@ -0,0 +1,73 @@ +import { OptionType } from '@/components/input/SelectInput'; +import * as yup from 'yup'; + +export type ReportExpenseFilterProps = { + location_id: string | null; + supplier_id: string | null; + kandang_id: string | null; + nonstock_id: string | null; + realization_date: string | null; + category: string | null; +}; + +export type ReportExpenseFilterFormType = { + location_id: OptionType | null; + supplier_id: OptionType | null; + kandang_id: OptionType | null; + nonstock_id: OptionType | null; + realization_date: string | null; + category: OptionType | null; +}; + +export const ReportExpenseFilterSchema = yup.object({ + location_id: yup + .mixed() + .nullable() + .test('is-not-empty', 'Lokasi wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + supplier_id: yup + .mixed() + .nullable() + .test('is-not-empty', 'Supplier wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + kandang_id: yup + .mixed() + .nullable() + .test('is-not-empty', 'Kandang wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + nonstock_id: yup + .mixed() + .nullable() + .test('is-not-empty', 'Produk wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), + realization_date: yup.string().nullable(), + category: yup + .mixed() + .nullable() + .test('is-not-empty', 'Kategori wajib dipilih', (value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return true; + }), +}) as yup.ObjectSchema; + +export type ReportExpenseFilterValues = yup.InferType< + typeof ReportExpenseFilterSchema +>; diff --git a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx b/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx deleted file mode 100644 index 65505a5f..00000000 --- a/src/components/pages/report/expense/pdf/styles/ReportExpenseStyles.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { StyleSheet } from '@react-pdf/renderer'; - -const pdfStyles = StyleSheet.create({ - page: { - fontSize: 18, - fontFamily: 'Helvetica', - padding: 20, - backgroundColor: '#FFFFFF', - }, - header: { - marginBottom: 20, - }, - logo: { - width: 120, - height: 30, - marginBottom: 8, - }, - companyInfo: { - fontSize: 18, - fontWeight: 'bold', - marginBottom: 4, - color: '#1f74bf', - }, - address: { - fontSize: 7, - color: '#666666', - maxWidth: 400, - marginBottom: 10, - }, - divider: { - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - marginBottom: 15, - }, - titleSection: { - flexDirection: 'row', - marginBottom: 20, - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - title: { - fontSize: 18, - fontWeight: 'bold', - flex: 3, - color: '#1f74bf', - }, - poInfo: { - flex: 1, - fontSize: 7, - textAlign: 'right', - }, - sectionTitle: { - fontSize: 14, - fontWeight: 'bold', - marginBottom: 8, - color: '#1f74bf', - }, - table: { - borderWidth: 1, - borderColor: '#000000', - marginBottom: 15, - }, - tableRow: { - flexDirection: 'row', - }, - tableHeader: { - backgroundColor: '#F5F5F5', - }, - tableCell: { - flex: 1, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - }, - tableCellLast: { - flex: 1, - padding: 3, - fontSize: 7, - }, - tableCellHeader: { - flex: 1, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - tableCellHeaderLast: { - flex: 1, - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - tableCellRight: { - flex: 1, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - tableCellRightLast: { - flex: 1, - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - tableCellNarrow: { - width: '1%', - minWidth: 20, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'center', - }, - tableCellNarrowHeader: { - width: '1%', - minWidth: 20, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - textAlign: 'center', - }, - tableCellWrap: { - flex: 1, - maxWidth: 80, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - flexWrap: 'wrap', - }, - tableCellWrapHeader: { - flex: 1, - maxWidth: 80, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - // Nested header styles - tableHeaderGroup: { - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - backgroundColor: '#F5F5F5', - }, - tableHeaderGroupLast: { - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - backgroundColor: '#F5F5F5', - }, - tableHeaderGroupTitle: { - padding: 3, - fontSize: 7, - fontWeight: 'bold', - textAlign: 'center', - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - }, - tableSubHeaderRow: { - flexDirection: 'row', - }, - // Specific width columns - tableCellXSmall: { - width: 30, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - }, - tableCellXSmallHeader: { - width: 30, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - tableCellSmall: { - width: 40, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - }, - tableCellSmallHeader: { - width: 40, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - tableCellMedium: { - width: 60, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - }, - tableCellMediumHeader: { - width: 60, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - fontWeight: 'bold', - backgroundColor: '#F5F5F5', - }, - tableCellRightXSmall: { - width: 30, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - tableCellRightSmall: { - width: 40, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - tableCellRightMedium: { - width: 60, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - tableBorderBottom: { - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - }, - grandTotalRow: { - flexDirection: 'row', - borderTopWidth: 1, - borderTopColor: '#000000', - borderTopStyle: 'solid', - }, - grandTotalLabel: { - flex: 3, - padding: 3, - fontSize: 7, - fontWeight: 'bold', - textAlign: 'right', - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - }, - grandTotalValue: { - flex: 1, - padding: 3, - fontSize: 7, - fontWeight: 'bold', - textAlign: 'right', - borderRightWidth: 0, - }, - allocationSection: { - marginBottom: 8, - }, - allocationTable: { - borderWidth: 1, - borderColor: '#000000', - }, - innerTable: { - marginTop: 5, - borderWidth: 1, - borderColor: '#000000', - }, - innerRow: { - flexDirection: 'row', - borderBottomWidth: 1, - borderBottomColor: '#000000', - borderBottomStyle: 'solid', - }, - innerCell: { - flex: 1, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - }, - innerCellLast: { - flex: 1, - padding: 3, - fontSize: 7, - }, - innerCellRight: { - flex: 1, - borderRightWidth: 1, - borderRightColor: '#000000', - borderRightStyle: 'solid', - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - innerCellRightLast: { - flex: 1, - padding: 3, - fontSize: 7, - textAlign: 'right', - }, - footer: { - marginTop: 30, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - footerCompany: { - fontSize: 18, - fontWeight: 'bold', - textAlign: 'right', - flex: 1, - color: '#1f74bf', - }, - specialInstructionTable: { - width: '60%', - maxWidth: 300, - borderWidth: 1, - borderColor: '#000000', - flex: 1, - }, -}); - -export default pdfStyles; diff --git a/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx b/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx new file mode 100644 index 00000000..f78344d7 --- /dev/null +++ b/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import { ColumnDef } from '@tanstack/react-table'; + +type ReportExpenseColumn = + | ColumnDef + | { + header: string; + columns: Array<{ + header: string; + accessorKey?: string; + cell?: (props: { + row: { original: ReportExpense }; + }) => React.ReactNode; + }>; + }; + +const ReportExpenseSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ReportExpenseColumn[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default ReportExpenseSkeleton; diff --git a/src/components/pages/report/expense/tab/ReportExpenseTab.tsx b/src/components/pages/report/expense/tab/ReportExpenseTab.tsx new file mode 100644 index 00000000..2581ec5c --- /dev/null +++ b/src/components/pages/report/expense/tab/ReportExpenseTab.tsx @@ -0,0 +1,755 @@ +'use client'; + +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; +import { Icon } from '@iconify/react'; +import Button from '@/components/Button'; +import Dropdown from '@/components/dropdown/Dropdown'; +import SelectInput, { useSelect } from '@/components/input/SelectInput'; +import DateInput from '@/components/input/DateInput'; +import { useFormik } from 'formik'; +import { + ReportExpenseFilterSchema, + type ReportExpenseFilterValues, +} from '@/components/pages/report/expense/filter/ReportExpenseFilter'; +import ExpenseStatusBadge from '@/components/pages/expense/ExpenseStatusBadge'; +import RealizationStatusBadge from '@/components/pages/expense/RealizationStatusBadge'; +import Table from '@/components/Table'; +import { cn, formatCurrency, formatDate } from '@/lib/helper'; +import { ReportExpense } from '@/types/api/report/report-expense'; +import { ReportExpenseApi } from '@/services/api/report'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { useReportTabStore } from '@/stores/report/report-tab.store'; +import Modal, { useModal } from '@/components/Modal'; +import Pagination from '@/components/Pagination'; +import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton'; +import { generateReportExpensePDF } from '../export/ReportExpenseExportPDF'; +import { generateReportExpenseExcel } from '../export/ReportExpenseExportXLSX'; +import toast from 'react-hot-toast'; +import { + KandangApi, + LocationApi, + NonstockApi, + SupplierApi, +} from '@/services/api/master-data'; +import { Supplier } from '@/types/api/master-data/supplier'; +import { Kandang } from '@/types/api/master-data/kandang'; +import { Nonstock } from '@/types/api/master-data/nonstock'; +import { ColumnDef } from '@tanstack/react-table'; +import { httpClient } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; + +interface ReportExpenseTabProps { + tabId: string; +} + +interface FilterParams { + location_id?: string; + supplier_id?: string; + kandang_id?: string; + nonstock_id?: string; + realization_date?: string; + category?: string; + search?: string; +} + +const ReportExpenseTab = ({ tabId }: ReportExpenseTabProps) => { + // ===== STATE MANAGEMENT ===== + const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); + const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); + const isAnyExportLoading = isPdfExportLoading || isExcelExportLoading; + + // ===== SUBMISSION STATE ===== + const [isSubmitted, setIsSubmitted] = useState(false); + const [filterParams, setFilterParams] = useState({}); + + // ===== PAGINATION STATE ===== + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const filterModal = useModal(); + + // ===== OPTIONS ===== + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocations, + loadMore: loadMoreLocations, + } = useSelect(LocationApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setSupplierInputValue, + options: supplierOptions, + isLoadingOptions: isLoadingSuppliers, + loadMore: loadMoreSuppliers, + } = useSelect(SupplierApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setKandangInputValue, + options: kandangOptions, + isLoadingOptions: isLoadingKandangs, + loadMore: loadMoreKandangs, + } = useSelect(KandangApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setNonstockInputValue, + options: nonstockOptions, + isLoadingOptions: isLoadingNonstocks, + loadMore: loadMoreNonstocks, + } = useSelect(NonstockApi.basePath, 'id', 'name', 'search'); + + const categoryOptions = useMemo( + () => [ + { value: 'BOP', label: 'BOP' }, + { value: 'NON-BOP', label: 'Non BOP' }, + ], + [] + ); + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + location_id: null, + supplier_id: null, + kandang_id: null, + nonstock_id: null, + realization_date: null, + category: null, + }, + validationSchema: ReportExpenseFilterSchema, + onSubmit: (values) => { + setFilterParams({ + location_id: values.location_id?.value + ? String(values.location_id.value) + : undefined, + supplier_id: values.supplier_id?.value + ? String(values.supplier_id.value) + : undefined, + kandang_id: values.kandang_id?.value + ? String(values.kandang_id.value) + : undefined, + nonstock_id: values.nonstock_id?.value + ? String(values.nonstock_id.value) + : undefined, + realization_date: values.realization_date || undefined, + category: values.category?.value + ? String(values.category.value) + : undefined, + }); + filterModal.closeModal(); + setIsSubmitted(true); + setPage(1); + }, + onReset: () => { + setFilterParams({}); + setIsSubmitted(false); + setPage(1); + }, + }); + + // ===== FILTER VALUES ===== + const locationValue = useMemo( + () => formik.values.location_id, + [formik.values.location_id] + ); + const supplierValue = useMemo( + () => formik.values.supplier_id, + [formik.values.supplier_id] + ); + const kandangValue = useMemo( + () => formik.values.kandang_id, + [formik.values.kandang_id] + ); + const nonstockValue = useMemo( + () => formik.values.nonstock_id, + [formik.values.nonstock_id] + ); + const categoryValue = useMemo( + () => formik.values.category, + [formik.values.category] + ); + + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + if (filterParams.location_id) count += 1; + if (filterParams.supplier_id) count += 1; + if (filterParams.kandang_id) count += 1; + if (filterParams.nonstock_id) count += 1; + if (filterParams.realization_date) count += 1; + if (filterParams.category) count += 1; + return count; + }, [filterParams]); + + const hasFilters = activeFiltersCount > 0; + + // ===== DATA FETCHING ===== + const { data: reportExpenseResponse, isLoading } = useSWR( + isSubmitted + ? () => { + const params = new URLSearchParams(); + if (filterParams.location_id) + params.append('location_id', filterParams.location_id); + if (filterParams.supplier_id) + params.append('supplier_id', filterParams.supplier_id); + if (filterParams.kandang_id) + params.append('kandang_id', filterParams.kandang_id); + if (filterParams.nonstock_id) + params.append('nonstock_id', filterParams.nonstock_id); + if (filterParams.realization_date) + params.append('realization_date', filterParams.realization_date); + if (filterParams.category) + params.append('category', filterParams.category); + params.append('page', String(page)); + params.append('limit', String(pageSize)); + + return [`${ReportExpenseApi.basePath}?${params.toString()}`]; + } + : null, + ([url]: string[]) => httpClient>(url) + ); + + const data: ReportExpense[] = useMemo( + () => + isResponseSuccess(reportExpenseResponse) + ? (reportExpenseResponse.data as ReportExpense[]) || [] + : [], + [reportExpenseResponse] + ); + + const meta = useMemo( + () => + isResponseSuccess(reportExpenseResponse) && reportExpenseResponse.meta + ? reportExpenseResponse.meta + : null, + [reportExpenseResponse] + ); + + // ===== EXPORT DATA FETCHER ===== + const reportExpenseExport = useCallback(async (): Promise< + ReportExpense[] | null + > => { + const params = new URLSearchParams(); + if (filterParams.location_id) + params.append('location_id', filterParams.location_id); + if (filterParams.supplier_id) + params.append('supplier_id', filterParams.supplier_id); + if (filterParams.kandang_id) + params.append('kandang_id', filterParams.kandang_id); + if (filterParams.nonstock_id) + params.append('nonstock_id', filterParams.nonstock_id); + if (filterParams.realization_date) + params.append('realization_date', filterParams.realization_date); + if (filterParams.category) params.append('category', filterParams.category); + params.append('limit', '100'); + params.append('page', '1'); + + const response = await httpClient>( + `${ReportExpenseApi.basePath}?${params.toString()}` + ); + + return isResponseSuccess(response) ? response.data : null; + }, [filterParams]); + + // ===== EXPORT HANDLERS ===== + const handleExportExcel = useCallback(async () => { + setIsExcelExportLoading(true); + try { + const allDataForExport = await reportExpenseExport(); + + if (!allDataForExport || allDataForExport.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } + + await generateReportExpenseExcel(allDataForExport); + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } + }, [reportExpenseExport]); + + const handleExportPDF = useCallback(async () => { + setIsPdfExportLoading(true); + try { + const allData = await reportExpenseExport(); + if (!allData || allData.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } + + const pdfParams = { + location_name: locationValue?.label, + supplier_name: supplierValue?.label, + realization_date: formik.values.realization_date || undefined, + }; + + await generateReportExpensePDF(allData, pdfParams); + + toast.success('PDF berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat PDF. Silakan coba lagi.'); + } finally { + setIsPdfExportLoading(false); + } + }, [ + reportExpenseExport, + locationValue, + supplierValue, + kandangValue, + nonstockValue, + categoryValue, + formik.values.realization_date, + ]); + + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); + + useEffect(() => { + setTabActions( + tabId, +
+ + + +
+ + + Export + +
+ + +
+ + } + > + + + +
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + handleExportExcel, + handleExportPDF, + setTabActions, + ]); + + useEffect(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); + + // ===== TABLE COLUMNS DEFINITION ===== + const columns = useMemo((): ColumnDef[] => { + return [ + { + header: 'No', + cell: (props) => (page - 1) * pageSize + props.row.index + 1, + }, + { + header: 'No. PO', + accessorKey: 'po_number', + }, + { + header: 'No. Referensi', + accessorKey: 'reference_number', + }, + { + header: 'Tanggal Realisasi', + accessorKey: 'realization_date', + cell: ({ row }) => { + return formatDate(row.original?.realization_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Tanggal Transaksi', + accessorKey: 'transaction_date', + cell: ({ row }) => { + return formatDate(row.original?.transaction_date, 'DD MMM, YYYY'); + }, + }, + { + header: 'Kategori', + accessorKey: 'category', + }, + { + header: 'Produk', + accessorFn: (row) => row.pengajuan?.nonstock?.name, + }, + { + header: 'Supplier', + accessorFn: (row) => row.supplier?.name, + }, + { + header: 'Lokasi', + accessorFn: (row) => row.kandang?.location?.name, + }, + { + header: 'Kandang', + accessorFn: (row) => row.kandang?.name, + }, + { + header: 'Pengajuan', + columns: [ + { + header: 'Qty', + id: 'qty_pengajuan', + accessorFn: (row) => row.pengajuan?.qty, + cell: ({ row }) => + row.original.pengajuan?.qty?.toLocaleString('id-ID') || '0', + }, + { + header: 'Harga', + id: 'harga_pengajuan', + accessorFn: (row) => row.pengajuan?.price, + cell: ({ row }) => + formatCurrency(row.original.pengajuan?.price || 0), + }, + { + header: 'Total', + id: 'total_pengajuan', + accessorFn: (row) => + (row.pengajuan?.qty || 0) * (row.pengajuan?.price || 0), + cell: ({ row }) => { + const total = + (row.original.pengajuan?.qty || 0) * + (row.original.pengajuan?.price || 0); + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Realisasi', + columns: [ + { + header: 'Qty', + id: 'qty_realisasi', + accessorFn: (row) => row.realisasi?.qty, + cell: ({ row }) => + row.original.realisasi?.qty?.toLocaleString('id-ID') || '0', + }, + { + header: 'Harga', + id: 'harga_realisasi', + accessorFn: (row) => row.realisasi?.price, + cell: ({ row }) => + formatCurrency(row.original.realisasi?.price || 0), + }, + { + header: 'Total', + id: 'total_realisasi', + accessorFn: (row) => + (row.realisasi?.qty || 0) * (row.realisasi?.price || 0), + cell: ({ row }) => { + const total = + (row.original.realisasi?.qty || 0) * + (row.original.realisasi?.price || 0); + return formatCurrency(total); + }, + }, + ], + }, + { + header: 'Status Pencairan', + cell: (props) => ( + + ), + }, + { + header: 'Status BOP', + cell: (props) => ( + + ), + }, + ]; + }, [page, pageSize]); + + return ( + <> +
+ {!isSubmitted ? ( + + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> + ) : isLoading ? ( +
+ +
+ ) : !data || data.length === 0 ? ( + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + ) : ( + <> +
+ {meta && ( +
+ + setPage((currPage) => + currPage > 1 ? currPage - 1 : currPage + ) + } + onNextPage={() => + setPage((currPage) => + meta && meta.total_pages && currPage < meta.total_pages + ? currPage + 1 + : currPage + ) + } + onPageChange={(pageNumber) => setPage(pageNumber)} + rowOptions={[10, 20, 50, 100]} + onRowChange={setPageSize} + /> +
+ )} + + )} + + + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+ +
+ {/* Modal Body */} +
+ { + formik.setFieldValue('location_id', val); + formik.setFieldValue('kandang_id', null); + }} + onInputChange={setLocationInputValue} + onMenuScrollToBottom={loadMoreLocations} + isClearable + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('kandang_id', val); + }} + onInputChange={setKandangInputValue} + onMenuScrollToBottom={loadMoreKandangs} + isClearable + isDisabled={!formik.values.location_id} + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('supplier_id', val); + }} + onInputChange={setSupplierInputValue} + onMenuScrollToBottom={loadMoreSuppliers} + isClearable + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('nonstock_id', val); + }} + onInputChange={setNonstockInputValue} + onMenuScrollToBottom={loadMoreNonstocks} + isClearable + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('category', val); + }} + isClearable + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue( + 'realization_date', + e.target.value || null + ); + }} + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ + ); +}; + +export default ReportExpenseTab; From e9da5210addddddab4be15c085775b10fbe5dd7f Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 10:22:13 +0700 Subject: [PATCH 35/36] refactor(FE): Simplify type definition for ReportExpenseColumn --- .../pages/report/expense/skeleton/ReportExpenseSkeleton.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx b/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx index f78344d7..3e13c539 100644 --- a/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx +++ b/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx @@ -11,9 +11,7 @@ type ReportExpenseColumn = columns: Array<{ header: string; accessorKey?: string; - cell?: (props: { - row: { original: ReportExpense }; - }) => React.ReactNode; + cell?: (props: { row: { original: ReportExpense } }) => React.ReactNode; }>; }; From ec3a0367ddfef1762816ba6d97493c6ab875eb59 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Fri, 13 Feb 2026 10:37:12 +0700 Subject: [PATCH 36/36] refactor(FE): Hide delete button if only one item remains in table --- .../table-view/SalesOrderProductTable.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/pages/marketing/form/table-view/SalesOrderProductTable.tsx b/src/components/pages/marketing/form/table-view/SalesOrderProductTable.tsx index 18f6145b..70282648 100644 --- a/src/components/pages/marketing/form/table-view/SalesOrderProductTable.tsx +++ b/src/components/pages/marketing/form/table-view/SalesOrderProductTable.tsx @@ -49,17 +49,19 @@ const SalesOrderProductTable = ({ > - + {data.length > 1 && ( + + )} )}