diff --git a/src/app/closing/detail/page.tsx b/src/app/closing/detail/page.tsx index 309addbd..96487258 100644 --- a/src/app/closing/detail/page.tsx +++ b/src/app/closing/detail/page.tsx @@ -3,7 +3,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import useSWR from 'swr'; -import ClosingDetail from '@/components/pages/closing/ClosingDetail'; +import ClosingDetail from '@/components/pages/closing/ClosingDetailTabs'; import { ClosingApi } from '@/services/api/closing'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; @@ -34,33 +34,6 @@ const ClosingDetailPage = () => { () => ProjectFlockKandangApi.getSingle(Number(kandangId)) ); - const { data: salesData, isLoading: isLoadingSales } = useSWR( - kandangId - ? `sales-${closingId}-${kandangId}` - : closingId - ? `sales-${closingId}` - : null, - () => - kandangId - ? ClosingApi.getPenjualanByKandang(Number(closingId), Number(kandangId)) - : ClosingApi.getPenjualan(Number(closingId)) - ); - - const { data: hppEkspedisiData, isLoading: isLoadingHppEkspedisi } = useSWR( - kandangId - ? `hpp-ekspedisi-${closingId}-${kandangId}` - : closingId - ? `hpp-ekspedisi-${closingId}` - : null, - () => - kandangId - ? ClosingApi.getHppEkspedisiByKandang( - Number(closingId), - Number(kandangId) - ) - : ClosingApi.getHppEkspedisi(Number(closingId)) - ); - if (!closingId) { router.back(); @@ -76,12 +49,7 @@ const ClosingDetailPage = () => { return; } - const isLoading = - isLoadingClosing || - isLoadingSales || - isLoadingHppEkspedisi || - isLoadingProject || - isLoadingKandang; + const isLoading = isLoadingClosing || isLoadingProject || isLoadingKandang; return (
@@ -91,12 +59,6 @@ const ClosingDetailPage = () => { { return ( -
+
); 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/app/report/marketing/page.tsx b/src/app/report/marketing/page.tsx index 87ed7a1a..cb79f109 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'; +import MarketingReportContent from '@/components/pages/report/marketing/MarketingTabs'; const MarketingReportPage = () => { - return ( -
- -
- ); + return ; }; export default MarketingReportPage; diff --git a/src/app/report/production-result/page.tsx b/src/app/report/production-result/page.tsx index cdac598c..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/ProductionResultContent'; +import ProductionResultTabs from '@/components/pages/report/production-result/ProductionResultTabs'; const ProductionResultReportPage = () => { return (
- +
); }; diff --git a/src/components/Table.tsx b/src/components/Table.tsx index b40d9db5..d9d81543 100644 --- a/src/components/Table.tsx +++ b/src/components/Table.tsx @@ -85,8 +85,8 @@ const DUMMY_SKELETON_DATA = Array.from({ length: 10 }, (_, index) => ({ })); const emptyContentDefaultValue = ( -
- +
+ Tidak ada data yang dapat ditampilkan...
@@ -452,6 +452,20 @@ const Table = ({ ); })} + + {(data.length === 0 || table.getRowModel().rows.length === 0) && + !isLoading && ( + + + {emptyContent} + + + )} ({
- {(data.length === 0 || table.getRowModel().rows.length === 0) && - !isLoading && - emptyContent} - {data.length > 0 && table.getRowModel().rows.length > 0 && !isLoading && 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'), diff --git a/src/components/pages/closing/ClosingDetail.tsx b/src/components/pages/closing/ClosingDetailTabs.tsx similarity index 57% rename from src/components/pages/closing/ClosingDetail.tsx rename to src/components/pages/closing/ClosingDetailTabs.tsx index c3c91a5a..dc8bd6f8 100644 --- a/src/components/pages/closing/ClosingDetail.tsx +++ b/src/components/pages/closing/ClosingDetailTabs.tsx @@ -5,28 +5,23 @@ import { useMemo, useState } from 'react'; import { Icon } from '@iconify/react'; import Button from '@/components/Button'; import Tabs from '@/components/Tabs'; -import ClosingGeneralInformationTable from '@/components/pages/closing/ClosingGeneralInformationTable'; -import ClosingSapronakTabContent from '@/components/pages/closing/ClosingSapronakTabContent'; -import ClosingProductionDataTabContent from '@/components/pages/closing/ClosingProductionDataTabContent'; +import ClosingGeneralInformationTable from '@/components/pages/closing/table/ClosingGeneralInformationTable'; +import SapronakClosingTab from '@/components/pages/closing/tab/SapronakClosingTab'; +import ProductionDataClosingTab from '@/components/pages/closing/tab/ProductionDataClosingTab'; -import { - ClosingGeneralInformation, - BaseClosingSales, - ClosingHppExpedition, -} from '@/types/api/closing'; -import ClosingSapronakCalculationTabContent from '@/components/pages/closing/ClosingSapronakCalculationTabContent'; -import ClosingOverheadTabContent from '@/components/pages/closing/ClosingOverheadTabContent'; -import ClosingFinanceTabContent from '@/components/pages/closing/ClosingFinanceTabContent'; -import SalesReportTable from '@/components/pages/closing/sale/SalesReportTable'; -import HppExpeditionReportTable from './hpp-ekspedisi/HppExpeditionReportTable'; +import { ClosingGeneralInformation } from '@/types/api/closing'; +import SapronakCalculationClosingTab from '@/components/pages/closing/tab/SapronakCalculationClosingTab'; +import OverheadClosingTab from '@/components/pages/closing/tab/OverheadClosingTab'; +import FinanceClosingTab from '@/components/pages/closing/tab/FinanceClosingTab'; +import SalesClosingTab from '@/components/pages/closing/tab/SalesClosingTab'; +import HppExpeditionClosingTab from '@/components/pages/closing/tab/HppExpeditionClosingTab'; import ClosingKandangList from '@/components/pages/closing/ClosingKandangList'; import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; +import { useClosingTabStore } from '@/stores/closing/closing-tab.store'; interface ClosingDetailProps { id: number; initialValue?: ClosingGeneralInformation; - salesData?: BaseClosingSales; - hppExpeditionData?: ClosingHppExpedition; projectData?: ProjectFlock; kandangData?: ProjectFlockKandang; } @@ -34,25 +29,24 @@ interface ClosingDetailProps { const ClosingDetail: React.FC = ({ id, initialValue, - salesData, - hppExpeditionData, projectData, kandangData, }) => { - const [activeTab, setActiveTab] = useState('sapronak'); + const [activeTabId, setActiveTabId] = useState('sapronak'); + const tabActions = useClosingTabStore((state) => state.tabActions); const closingDetailTabs = useMemo(() => { const validTabs = [ { id: 'sapronak', label: 'Sapronak', - content: , + content: , }, { id: 'perhitunganSapronak', label: 'Perhitungan Sapronak', content: ( - @@ -61,13 +55,13 @@ const ClosingDetail: React.FC = ({ { id: 'penjualan', label: 'Penjualan', - content: , + content: , }, { id: 'overhead', label: 'Overhead', content: ( - = ({ { id: 'hppEkspedisi', label: 'HPP Ekspedisi', - content: , + content: , }, { id: 'dataProduksi', label: 'Data Produksi', - content: , + content: , }, { id: 'keuangan', label: 'Keuangan', - content: , + content: , }, ]; return validTabs; - }, [initialValue]); + }, [initialValue, kandangData, id]); return ( <> -
+
diff --git a/src/components/pages/closing/ClosingFinanceTabContent.tsx b/src/components/pages/closing/ClosingFinanceTabContent.tsx deleted file mode 100644 index 92386178..00000000 --- a/src/components/pages/closing/ClosingFinanceTabContent.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import ClosingFinanceTable from '@/components/pages/closing/ClosingFinanceTable'; - -const ClosingFinanceTabContent = ({ - projectFlockId, -}: { - projectFlockId: number; -}) => { - return ( -
- {projectFlockId && ( - - )} -
- ); -}; - -export default ClosingFinanceTabContent; diff --git a/src/components/pages/closing/ClosingFinanceTable.tsx b/src/components/pages/closing/ClosingFinanceTable.tsx deleted file mode 100644 index 6225f5e7..00000000 --- a/src/components/pages/closing/ClosingFinanceTable.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import Card from '@/components/Card'; -import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { formatCurrency, formatTitleCase } from '@/lib/helper'; -import { ClosingApi } from '@/services/api/closing'; -import { HppItem, ProfitLossItem } from '@/types/api/closing'; -import { useSearchParams } from 'next/navigation'; -import { useMemo } from 'react'; -import useSWR from 'swr'; - -const ClosingFinanceTable = ({ - projectFlockId, -}: { - projectFlockId: number; -}) => { - const searchParams = useSearchParams(); - const kandangId = searchParams.get('kandangId'); - - const { data: finance, isLoading } = useSWR( - `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`, - () => - ClosingApi.getFinance( - projectFlockId, - kandangId ? Number(kandangId) : undefined - ) - ); - - const hppTableData: HppItem[] = useMemo(() => { - if (isResponseSuccess(finance)) { - const customItems = { - label: 'HPP dan Pengeluaran', - code: 'custom_row', - } as HppItem; - const purchases = finance.data.hpp.items.filter( - (item) => item.category === 'purchase' - ); - const totalBudgeting = { - label: 'HPP dan Bahan Baku', - code: 'custom_row', - } as HppItem; - const overheads = finance.data.hpp.items.filter( - (item) => item.category === 'overhead' - ); - return [customItems, ...purchases, totalBudgeting, ...overheads]; - } - return []; - }, [finance]); - - const profitLossTableData: ProfitLossItem[] = useMemo(() => { - if (isResponseSuccess(finance)) { - const incomes = finance.data.profit_loss.items.filter( - (item) => item.type === 'income' - ); - const purchases = finance.data.profit_loss.items.filter( - (item) => item.type === 'purchase' - ); - const overheads = finance.data.profit_loss.items.filter( - (item) => item.type === 'overhead' - ); - const grossProfit = { - label: 'LABA RUGI BRUTO', - code: 'custom_row', - type: 'gross_profit', - rp_per_bird: - finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0, - rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0, - amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0, - } as ProfitLossItem; - const subtotal = { - label: 'Subtotal', - code: 'custom_row', - type: 'subtotal', - rp_per_bird: - finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0, - rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0, - amount: finance.data.profit_loss.summary.sub_total.amount ?? 0, - } as ProfitLossItem; - return [...incomes, ...purchases, grossProfit, ...overheads, subtotal]; - } - return []; - }, [finance]); - - return ( -
- <> - -
-
-
Laba Rugi Brutto
-
- {isResponseSuccess(finance) - ? formatCurrency( - finance.data.profit_loss.summary.gross_profit.amount - ) - : '-'} -
-
-
-
Laba Rugi Netto
-
- {isResponseSuccess(finance) - ? formatCurrency( - finance.data.profit_loss.summary.net_profit.amount - ) - : '-'} -
-
-
-
- -
- - data={hppTableData} - isLoading={isLoading} - columns={[ - { - header: 'No.', - enableSorting: false, - accessorFn: (item, index) => { - if (item.code === 'custom_row') return '-'; - const dataRowsBefore = hppTableData - .slice(0, index) - .filter((row) => row.code !== 'custom_row').length; - return dataRowsBefore + 1; - }, - footer: (props) => { - return 'HPP'; - }, - }, - { - header: 'Jenis', - enableSorting: false, - accessorFn: (item) => formatTitleCase(item.label || '-'), - }, - { - header: 'Budgeting', - enableSorting: false, - columns: [ - { - header: 'Rp/Ekor', - id: 'budgeting_rp_per_bird', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.budgeting?.rp_per_bird || 0), - footer: (props) => { - return props.column.id === 'budgeting_rp_per_bird' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.budgeting - ?.rp_per_bird || 0 - ) - : '-'; - }, - }, - { - header: 'Rp/Kg', - id: 'budgeting_rp_per_kg', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.budgeting?.rp_per_kg || 0), - footer: (props) => { - return props.column.id === 'budgeting_rp_per_kg' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.budgeting?.rp_per_kg || - 0 - ) - : '-'; - }, - }, - { - header: 'Jumlah (Rp)', - id: 'budgeting_amount', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.budgeting?.amount || 0), - footer: (props) => { - return props.column.id === 'budgeting_amount' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.budgeting?.amount || 0 - ) - : '-'; - }, - }, - ], - }, - { - header: 'Realization', - enableSorting: false, - columns: [ - { - header: 'Rp/Ekor', - id: 'realization_rp_per_bird', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.realization?.rp_per_bird || 0), - footer: (props) => { - return props.column.id === 'realization_rp_per_bird' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.realization - ?.rp_per_bird || 0 - ) - : '-'; - }, - }, - { - header: 'Rp/Kg', - id: 'realization_rp_per_kg', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.realization?.rp_per_kg || 0), - footer: (props) => { - return props.column.id === 'realization_rp_per_kg' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.realization - ?.rp_per_kg || 0 - ) - : '-'; - }, - }, - { - header: 'Jumlah (Rp)', - id: 'realization_amount', - enableSorting: false, - accessorFn: (item) => - formatCurrency(item.realization?.amount || 0), - footer: (props) => { - return props.column.id === 'realization_amount' && - isResponseSuccess(finance) - ? formatCurrency( - finance.data.hpp.summary?.realization?.amount || 0 - ) - : '-'; - }, - }, - ], - }, - ]} - renderCustomRow={(row) => { - const rowData = row.original; - if (rowData.code === 'custom_row') { - return ( - - - -
- {formatTitleCase(rowData.label ?? '-')} -
- - - ); - } - return null; - }} - renderFooter={isResponseSuccess(finance)} - /> -
-
- -
- - data={profitLossTableData} - isLoading={isLoading} - columns={[ - { - header: 'Jenis', - enableSorting: false, - accessorFn: (item) => item.label, - cell: (item) => ( -
- {formatTitleCase(item.row.original.label || '-')} -
- ), - footer: () => ( -
LABA RUGI NETTO
- ), - }, - { - header: 'Rp/Ekor', - enableSorting: false, - accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), - footer: () => ( -
- {isResponseSuccess(finance) - ? formatCurrency( - finance.data.profit_loss.summary.net_profit - .rp_per_bird || 0 - ) - : formatCurrency(0)} -
- ), - }, - { - header: 'Rp/Kg', - enableSorting: false, - accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), - footer: () => ( -
- {isResponseSuccess(finance) - ? formatCurrency( - finance.data.profit_loss.summary.net_profit - .rp_per_kg || 0 - ) - : formatCurrency(0)} -
- ), - }, - { - header: 'Jumlah (Rp)', - enableSorting: false, - accessorFn: (item) => formatCurrency(item.amount || 0), - footer: () => ( -
- {isResponseSuccess(finance) - ? formatCurrency( - finance.data.profit_loss.summary.net_profit - .amount || 0 - ) - : formatCurrency(0)} -
- ), - }, - ]} - renderCustomRow={(row) => { - const rowData = row.original; - if (rowData.code === 'custom_row') { - return ( - - -
- {formatTitleCase(rowData.label ?? '-')} -
- - -
- {formatCurrency(rowData.rp_per_bird ?? 0)} -
- - -
- {formatCurrency(rowData.rp_per_kg ?? 0)} -
- - -
- {formatCurrency(rowData.amount ?? 0)} -
- - - ); - } - return null; - }} - className={{ - paginationClassName: 'hidden', - }} - renderFooter={isResponseSuccess(finance)} - /> -
-
- -
- ); -}; - -export default ClosingFinanceTable; diff --git a/src/components/pages/closing/ClosingKandangList.tsx b/src/components/pages/closing/ClosingKandangList.tsx index dd3083a7..4ecf607f 100644 --- a/src/components/pages/closing/ClosingKandangList.tsx +++ b/src/components/pages/closing/ClosingKandangList.tsx @@ -10,18 +10,18 @@ const ClosingKandangList = ({ projectData?: ProjectFlock; }) => { return ( -
+
-

Kandang

-
+

Kandang

+
{projectData?.kandangs?.map((kandang) => ( diff --git a/src/components/pages/closing/ClosingProductionDataTabContent.tsx b/src/components/pages/closing/ClosingProductionDataTabContent.tsx deleted file mode 100644 index 9295d283..00000000 --- a/src/components/pages/closing/ClosingProductionDataTabContent.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import useSWR from 'swr'; -import { ClosingApi } from '@/services/api/closing'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { formatNumber } from '@/lib/helper'; - -interface ClosingProductionDataTabContentProps { - projectFlockId: number; -} - -const ClosingProductionDataTabContent = ({ - projectFlockId, -}: ClosingProductionDataTabContentProps) => { - const searchParams = useSearchParams(); - const kandangId = searchParams.get('kandangId'); - - const { data: productionData, isLoading } = useSWR( - `${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`, - () => ClosingApi.getProductionData(projectFlockId, Number(kandangId)) - ); - - if (isLoading) { - return ( -
- -
- ); - } - - if (!productionData || !isResponseSuccess(productionData)) { - return ( -
- Gagal memuat data produksi. -
- ); - } - - const { purchase, sales, performance } = productionData.data; - - // Helper for consistent row styling - const DataRow = ({ - label, - value, - unit = '', - valueClassName = 'font-bold text-gray-800', - unitClassName = 'text-gray-500 w-12 text-right', - }: { - label: string; - value: string | number; - unit?: string; - valueClassName?: string; - unitClassName?: string; - }) => ( -
- {label} -
- {value} - {unit && {unit}} -
-
- ); - - return ( -
-

Data Produksi

- -
- {/* Left Column */} -
- {/* Purchase Section */} -
-

- Pembelian -

-
- - - - - -
-
- - {/* Sales Section */} -
-

- Penjualan -

-
- {/* Chicken Sales */} -
- - - - -
- - {/* Egg Sales (if available) */} - {sales.egg && ( - <> -
-
- - - - -
- - )} -
-
-
- - {/* Divider Line (Absolute centered) */} -
- - {/* Right Column */} -
- {/* Performance Section */} -
-

- Performance -

-
- - - - - - {/* - */} - - - - - - - {/* Laying Specific Fields */} - {performance.hen_day_act !== undefined && ( - <> - - - - )} - - {performance.egg_mass !== undefined && ( - <> - - - - )} - - {performance.egg_weight !== undefined && ( - <> - - - - )} - - {performance.hen_housed_act !== undefined && ( - <> - - - - )} -
-
-
-
-
- ); -}; - -export default ClosingProductionDataTabContent; diff --git a/src/components/pages/closing/ClosingSapronakCalculationTable.tsx b/src/components/pages/closing/ClosingSapronakCalculationTable.tsx deleted file mode 100644 index 77cef803..00000000 --- a/src/components/pages/closing/ClosingSapronakCalculationTable.tsx +++ /dev/null @@ -1,268 +0,0 @@ -'use client'; - -import Card from '@/components/Card'; - -import Table from '@/components/Table'; -import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; -import { - RowSapronakCalculation, - TotalSapronakCalculation, -} from '@/types/api/closing'; -import { ColumnDef } from '@tanstack/react-table'; -import { useMemo } from 'react'; -import useSWR from 'swr'; -import { ClosingApi } from '@/services/api/closing'; -import { isResponseSuccess } from '@/lib/api-helper'; -import { ClosingGeneralInformation } from '@/types/api/closing'; -import { useSearchParams } from 'next/navigation'; - -interface ClosingSapronakCalculationTableProps { - projectFlockId: number; - closingGeneralInformation?: ClosingGeneralInformation; -} - -const ClosingSapronakCalculationTable = ({ - projectFlockId, - closingGeneralInformation, -}: ClosingSapronakCalculationTableProps) => { - const searchParams = useSearchParams(); - const kandangId = searchParams.get('kandangId'); - - const { data: sapronakCalculation, isLoading } = useSWR( - `/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`, - () => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)), - { - keepPreviousData: true, - } - ); - - // Helper function to create columns with footer support - const createColumns = ( - total?: TotalSapronakCalculation - ): ColumnDef[] => [ - { - header: 'Tanggal', - accessorKey: 'date', - cell: (props) => - props.row.original.date - ? formatDate(props.row.original.date, 'DD MMM YYYY') - : '-', - footer: 'Total', - }, - { - header: 'No. Referensi', - accessorKey: 'reference_number', - cell: (props) => (props.row.original.reference_number as string) || '-', - footer: '', - }, - { - header: 'QTY Masuk', - accessorKey: 'qty_in', - cell: (props) => - props.row.original.qty_in - ? formatNumber(props.row.original.qty_in as number) - : '0', - footer: total - ? () => ( -
- {total?.qty_in ? formatNumber(total?.qty_in) : '0'} -
- ) - : '', - }, - { - header: 'QTY Keluar', - accessorKey: 'qty_out', - cell: (props) => - props.row.original.qty_out - ? formatNumber(props.row.original.qty_out as number) - : '0', - footer: total - ? () => ( -
- {total?.qty_out ? formatNumber(total?.qty_out) : '0'} -
- ) - : '', - }, - { - header: 'QTY Pakai', - accessorKey: 'qty_used', - cell: (props) => - props.row.original.qty_used - ? formatNumber(props.row.original.qty_used as number) - : '0', - footer: total - ? () => ( -
- {total?.qty_used ? formatNumber(total?.qty_used) : '0'} -
- ) - : '', - }, - { - header: 'Uraian', - accessorKey: 'description', - cell: (props) => (props.row.original.description as string) || '-', - footer: '', - }, - { - header: 'Kategori Produk', - accessorKey: 'product_category', - cell: (props) => (props.row.original.product_category as string) || '-', - footer: '', - }, - { - header: 'Harga Beli/Qty (Rp)', - accessorKey: 'unit_price', - cell: (props) => - props.row.original.unit_price - ? formatCurrency(props.row.original.unit_price as number) - : '-', - footer: total - ? () => ( -
- {total?.avg_unit_price - ? formatCurrency(total?.avg_unit_price) - : '-'} -
- ) - : '', - }, - { - header: 'Total Harga (Rp)', - accessorKey: 'total_amount', - cell: (props) => - props.row.original.total_amount - ? formatCurrency(props.row.original.total_amount as number) - : '-', - footer: total - ? () => ( -
- {total?.total_amount ? formatCurrency(total?.total_amount) : '-'} -
- ) - : '', - }, - { - header: 'Keterangan', - accessorKey: 'notes', - cell: (props) => (props.row.original.notes as string) || '-', - footer: '', - }, - ]; - - // Memoize columns untuk setiap kategori - const docColumns = useMemo( - () => - isResponseSuccess(sapronakCalculation) - ? createColumns(sapronakCalculation.data?.doc?.total) - : createColumns(), - [sapronakCalculation] - ); - - const ovkColumns = useMemo( - () => - isResponseSuccess(sapronakCalculation) - ? createColumns(sapronakCalculation.data?.ovk?.total) - : createColumns(), - [sapronakCalculation] - ); - - const pakanColumns = useMemo( - () => - isResponseSuccess(sapronakCalculation) - ? createColumns(sapronakCalculation.data?.pakan?.total) - : createColumns(), - [sapronakCalculation] - ); - - return ( -
- {/* Table DOC jika kategori Project Flock Growing */} - - - data={ - isResponseSuccess(sapronakCalculation) - ? (sapronakCalculation.data?.doc?.rows ?? []) - : [] - } - columns={docColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter={ - isResponseSuccess(sapronakCalculation) && - sapronakCalculation.data?.doc?.rows.length > 0 - } - /> - - - - - data={ - isResponseSuccess(sapronakCalculation) - ? (sapronakCalculation.data?.ovk?.rows ?? []) - : [] - } - columns={ovkColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter={ - isResponseSuccess(sapronakCalculation) && - sapronakCalculation.data?.ovk?.rows.length > 0 - } - /> - - - - - data={ - isResponseSuccess(sapronakCalculation) - ? (sapronakCalculation.data?.pakan?.rows ?? []) - : [] - } - columns={pakanColumns} - className={{ - containerClassName: 'my-4', - }} - renderFooter={ - isResponseSuccess(sapronakCalculation) && - sapronakCalculation.data?.pakan?.rows.length > 0 - } - /> - -
- ); -}; - -export default ClosingSapronakCalculationTable; diff --git a/src/components/pages/closing/ClosingSapronakTabContent.tsx b/src/components/pages/closing/ClosingSapronakTabContent.tsx deleted file mode 100644 index 03c3c984..00000000 --- a/src/components/pages/closing/ClosingSapronakTabContent.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import ClosingIncomingSapronaksTable from '@/components/pages/closing/ClosingIncomingSapronaksTable'; -import ClosingOutgoingSapronaksTable from '@/components/pages/closing/ClosingOutgoingSapronaksTable'; -import ClosingIncomingSapronaksSummaryTable from '@/components/pages/closing/ClosingIncomingSapronaksSummaryTable'; -import ClosingOutgoingSapronaksSummaryTable from './ClosingOutgoingSapronaksSummaryTable'; - -interface ClosingSapronakTableProps { - projectFlockId?: number; -} - -const ClosingSapronakTabContent = ({ - projectFlockId, -}: ClosingSapronakTableProps) => { - return ( -
- {projectFlockId && ( - <> - - - - - - - - - )} -
- ); -}; - -export default ClosingSapronakTabContent; diff --git a/src/components/pages/closing/ClosingsTable.tsx b/src/components/pages/closing/ClosingsTable.tsx index dc9609ac..12114110 100644 --- a/src/components/pages/closing/ClosingsTable.tsx +++ b/src/components/pages/closing/ClosingsTable.tsx @@ -1,68 +1,116 @@ 'use client'; -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { ChangeEventHandler, useEffect, useState, useMemo } from 'react'; import useSWR from 'swr'; import { CellContext, ColumnDef, SortingState } from '@tanstack/react-table'; +import { useRouter } from 'next/navigation'; import { Icon } from '@iconify/react'; import Table from '@/components/Table'; import DebouncedTextInput from '@/components/input/DebouncedTextInput'; import Button from '@/components/Button'; -import SelectInput, { - OptionType, - useSelect, -} from '@/components/input/SelectInput'; -import RowDropdownOptions from '@/components/table/RowDropdownOptions'; -import RowCollapseOptions from '@/components/table/RowCollapseOptions'; -import RowOptionsMenuWrapper from '@/components/table/RowOptionsMenuWrapper'; +import SelectInput, { useSelect } from '@/components/input/SelectInput'; +import PopoverButton from '@/components/popover/PopoverButton'; +import PopoverContent from '@/components/popover/PopoverContent'; import RequirePermission from '@/components/helper/RequirePermission'; +import StatusBadge from '@/components/helper/StatusBadge'; +import Modal, { useModal } from '@/components/Modal'; +import SelectInputRadio from '@/components/input/SelectInputRadio'; +import { useFormik } from 'formik'; -import { cn, formatCurrency, formatDate } from '@/lib/helper'; +import { cn, formatDate } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { LocationApi } from '@/services/api/master-data'; import { Location } from '@/types/api/master-data/location'; import { ClosingApi } from '@/services/api/closing'; import { Closing } from '@/types/api/closing'; - -const PROJECT_STATUS_OPTIONS = [ - { - value: 1, - label: 'Pengajuan', - }, - { - value: 2, - label: 'Aktif', - }, -]; +import { Color } from '@/types/theme'; +import { + ClosingFilterSchema, + ClosingFilterType, +} from '@/components/pages/closing/filter/ClosingFilter'; +import ClosingTableSkeleton from '@/components/pages/closing/skeleton/ClosingTableSkeleton'; const RowOptionsMenu = ({ - type = 'dropdown', props, + popoverPosition = 'bottom', + detailClickHandler, }: { - type: 'dropdown' | 'collapse'; props: CellContext; + popoverPosition: 'bottom' | 'top'; + detailClickHandler: (id: number) => void; }) => { + const popoverId = `closing#${props.row.original.id}`; + const popoverAnchorName = `--anchor-closing#${props.row.original.id}`; + + const closePopover = () => { + document.getElementById(popoverId)?.hidePopover(); + }; + + const detailClickHandlerWrapper = () => { + detailClickHandler(props.row.original.id); + closePopover(); + }; + return ( - -
- - - -
-
+
+ + + + + +
+ + + +
+
+
); }; const ClosingsTable = () => { + // ===== ROUTER ===== + const router = useRouter(); + + // ===== STATUS BADGE COLOR HELPER ===== + const getProjectStatusBadgeColor = (status: string): Color => { + const normalizedValue = status.toLowerCase(); + + if (normalizedValue === 'aktif') { + return 'success'; + } + + if (normalizedValue === 'pengajuan') { + return 'neutral'; + } + + return 'neutral'; + }; + + // ===== FILTER MODAL STATE ===== + const filterModal = useModal(); + const { state: tableFilterState, updateFilter, @@ -72,36 +120,67 @@ const ClosingsTable = () => { } = useTableFilter({ initial: { search: '', - nameSort: '', - transactionDate: '', - realizationDate: '', - locationId: '', - projectStatus: '', - userId: '', + // nameSort: '', + // transactionDate: '', + // realizationDate: '', + location_id: '', + project_status: '', + // userId: '', }, paramMap: { page: 'page', pageSize: 'limit', - nameSort: 'sort_name', - transactionDate: 'transaction_date', - realizationDate: 'realization_date', - locationId: 'location_id', - projectStatus: 'project_status', - userId: 'user_id', + // nameSort: 'sort_name', + // transactionDate: 'transaction_date', + // realizationDate: 'realization_date', + // locationId: 'location_id', + // projectStatus: 'project_status', + // userId: 'user_id', + search: 'search', + location_id: 'location_id', + project_status: 'project_status', }, }); + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + location_id: null, + project_status: null, + }, + validationSchema: ClosingFilterSchema, + onSubmit: (values, { setSubmitting }) => { + updateFilter('location_id', values.location_id || ''); + updateFilter('project_status', values.project_status || ''); + filterModal.closeModal(); + setSubmitting(false); + }, + onReset: () => { + updateFilter('location_id', ''); + updateFilter('project_status', ''); + }, + }); + + // ===== DATA FETCHING ===== const { data: closings, isLoading: isLoadingClosings } = useSWR( `${ClosingApi.basePath}${getTableFilterQueryString()}`, ClosingApi.getAllFetcher ); + const data = useMemo( + () => + isResponseSuccess(closings) ? (closings?.data as Closing[]) || [] : [], + [closings] + ); + + // ===== PAGINATION & STATE ===== const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); + // ===== TABLE COLUMNS ===== const closingsColumns: ColumnDef[] = [ { - header: '#', + header: 'No', cell: (props) => props.row.index + 1, }, { @@ -133,6 +212,19 @@ const ClosingsTable = () => { { accessorKey: 'project_status', header: 'Status', + cell: (props) => { + const status = props.row.original.project_status; + const badgeColor = getProjectStatusBadgeColor(status); + return ( + + ); + }, }, { header: 'Aksi', @@ -142,27 +234,24 @@ const ClosingsTable = () => { const currentRowRelativeIndex = currentPageRows.findIndex((r) => r.id === props.row.id) + 1; - const isLast2Rows = currentRowRelativeIndex > currentPageSize - 3; + const isLast2Rows = currentRowRelativeIndex > currentPageSize - 2; + + const detailClickHandler = (id: number) => { + router.push(`/closing/detail/?closingId=${id}`); + }; return ( - <> - {currentPageSize > 3 && ( - - - - )} - - {currentPageSize <= 3 && ( - - - - )} - + ); }, }, ]; + // ===== LOCATION OPTIONS ===== const { setInputValue: setLocationInputValue, options: locationOptions, @@ -170,115 +259,246 @@ const ClosingsTable = () => { loadMore: loadMoreLocations, } = useSelect(LocationApi.basePath, 'id', 'name'); - const [selectedLocation, setSelectedLocation] = useState( - null + // ===== PROJECT STATUS OPTIONS ===== + const projectStatusOptions = useMemo( + () => [ + { value: '1', label: 'Pengajuan' }, + { value: '2', label: 'Aktif' }, + ], + [] ); - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedLocation(val as OptionType); - updateFilter( - 'locationId', - val ? ((val as OptionType).value as string) : '' + // ===== FILTER HELPERS ===== + const locationIdValue = useMemo(() => { + if (!formik.values.location_id) return null; + return ( + locationOptions.find( + (opt) => String(opt.value) === formik.values.location_id + ) || null ); - }; + }, [formik.values.location_id, locationOptions]); - const [selectedProjectStatus, setSelectedProjectStatus] = - useState(null); - - const projectStatusChangeHandler = ( - val: OptionType | OptionType[] | null - ) => { - setSelectedProjectStatus(val as OptionType); - updateFilter( - 'projectStatus', - val ? ((val as OptionType).value as string) : '' + const projectStatusValue = useMemo(() => { + if (!formik.values.project_status) return null; + return ( + projectStatusOptions.find( + (opt) => opt.value === formik.values.project_status + ) || null ); - }; + }, [formik.values.project_status, projectStatusOptions]); + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; + + if (tableFilterState.location_id) { + count += 1; + } + + if (tableFilterState.project_status) { + count += 1; + } + + return count; + }, [tableFilterState.location_id, tableFilterState.project_status]); + + const hasFilters = activeFiltersCount > 0; + + // ===== SEARCH CHANGE HANDLER ===== const searchChangeHandler: ChangeEventHandler = (e) => { updateFilter('search', e.target.value); }; + // ===== HANDLE FILTER MODAL OPEN ===== + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + // track sorting useEffect(() => { const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); if (!isNameSorted) { - updateFilter('nameSort', ''); + // updateFilter('nameSort', ''); } else { - updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); + // updateFilter('nameSort', isNameSorted.desc ? 'desc' : 'asc'); } - }, [sorting, updateFilter]); + }, [sorting]); return ( <> -
-
-
-
+
+
+
+
-
- -
- + } className={{ - wrapper: 'col-span-12 sm:col-span-6', + wrapper: 'w-full min-w-24 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: + 'placeholder:font-semibold placeholder:text-base-content/50', }} /> - +
-
- - data={isResponseSuccess(closings) ? closings?.data : []} - columns={closingsColumns} - pageSize={tableFilterState.pageSize} - onPageSizeChange={setPageSize} - rowOptions={[10, 20, 50, 100]} - page={isResponseSuccess(closings) ? closings?.meta?.page : 0} - totalItems={ - isResponseSuccess(closings) ? closings?.meta?.total_results : 0 - } - onPageChange={setPage} - isLoading={isLoadingClosings} - sorting={sorting} - setSorting={setSorting} - rowSelection={rowSelection} - setRowSelection={setRowSelection} - className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(closings) && closings?.data?.length === 0, - }), - }} - /> + {isLoadingClosings ? ( +
+ +
+ ) : data.length === 0 ? ( + + } + title='Data Closing Belum Tersedia' + subtitle='Tidak ada data closing untuk saat ini.' + /> + ) : ( + + data={isResponseSuccess(closings) ? closings?.data : []} + columns={closingsColumns} + pageSize={tableFilterState.pageSize} + onPageSizeChange={setPageSize} + rowOptions={[10, 20, 50, 100]} + page={isResponseSuccess(closings) ? closings?.meta?.page : 0} + totalItems={ + isResponseSuccess(closings) ? closings?.meta?.total_results : 0 + } + onPageChange={setPage} + isLoading={isLoadingClosings} + sorting={sorting} + setSorting={setSorting} + rowSelection={rowSelection} + setRowSelection={setRowSelection} + className={{ + containerClassName: cn('mt-3', { + 'w-full mb-0': + isResponseSuccess(closings) && closings?.data?.length === 0, + }), + headerColumnClassName: 'text-nowrap', + }} + /> + )} +
+ + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+ +
+
+
+ { + if (!Array.isArray(val)) { + formik.setFieldValue( + 'location_id', + val?.value ? String(val.value) : null + ); + } + }} + onInputChange={setLocationInputValue} + isLoading={isLoadingLocationOptions} + isClearable + onMenuScrollToBottom={loadMoreLocations} + className={{ wrapper: 'w-full' }} + /> + + { + if (!Array.isArray(val)) { + formik.setFieldValue('project_status', val?.value || null); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + /> +
+ + {/* Modal Footer */} +
+ + +
+
+
); }; diff --git a/src/components/pages/closing/filter/ClosingFilter.ts b/src/components/pages/closing/filter/ClosingFilter.ts new file mode 100644 index 00000000..77f0c9d2 --- /dev/null +++ b/src/components/pages/closing/filter/ClosingFilter.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup'; + +export type ClosingFilterType = { + location_id: string | null; + project_status: string | null; +}; + +export const ClosingFilterSchema = yup.object({ + location_id: yup.string().nullable(), + project_status: yup.string().nullable(), +}); + +export type ClosingFilterValues = yup.InferType; diff --git a/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx b/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx deleted file mode 100644 index da89d963..00000000 --- a/src/components/pages/closing/hpp-ekspedisi/HppExpeditionReportTable.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import React, { useMemo } from 'react'; -import { ColumnDef } from '@tanstack/react-table'; -import Table from '@/components/Table'; -import Card from '@/components/Card'; -import { formatCurrency } from '@/lib/helper'; -import { BaseHppExpedition, BaseExpeditionCost } from '@/types/api/closing'; - -interface HppExpeditionReportTableProps { - type?: 'detail'; - initialValues?: BaseHppExpedition; -} - -const HppExpeditionReportTable = ({ - initialValues, -}: HppExpeditionReportTableProps) => { - const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => { - return initialValues?.expedition_costs || []; - }, [initialValues]); - - const totals = useMemo(() => { - const totalHpp = initialValues?.total_hpp_amount || 0; - - return { - totalHpp, - }; - }, [initialValues]); - - const costOfRevenueExpeditionColumns: ColumnDef[] = - useMemo( - () => [ - { - id: 'id', - accessorKey: 'id', - header: 'No', - cell: (props) => { - return
{props.row.index + 1}
; - }, - footer: () => ( -
- Total HPP Ekspedisi -
- ), - }, - { - id: 'expedition_vendor_name', - accessorKey: 'expedition_vendor_name', - header: 'Nama Ekspedisi', - cell: (props) => props.getValue() || '-', - }, - { - id: 'hpp_amount', - accessorKey: 'hpp_amount', - header: 'HPP Ekspedisi', - cell: (props) => { - const value = props.getValue() as number; - return
{formatCurrency(value)}
; - }, - footer: () => ( -
- {formatCurrency(totals.totalHpp)} -
- ), - }, - ], - [totals] - ); - - return ( - <> -
-
-

HPP Ekspedisi

- - 0} - className={{ - tableWrapperClassName: 'overflow-x-auto', - tableClassName: 'w-full table-auto text-sm', - headerRowClassName: 'border-b border-b-gray-200', - headerColumnClassName: - 'px-4 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end whitespace-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', - }} - /> - - - - - ); -}; - -export default HppExpeditionReportTable; diff --git a/src/components/pages/closing/skeleton/ClosingTabSkeleton.tsx b/src/components/pages/closing/skeleton/ClosingTabSkeleton.tsx new file mode 100644 index 00000000..44defca8 --- /dev/null +++ b/src/components/pages/closing/skeleton/ClosingTabSkeleton.tsx @@ -0,0 +1,36 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { ColumnDef } from '@tanstack/react-table'; + +const ClosingTabSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default ClosingTabSkeleton; diff --git a/src/components/pages/closing/skeleton/ClosingTableSkeleton.tsx b/src/components/pages/closing/skeleton/ClosingTableSkeleton.tsx new file mode 100644 index 00000000..4b59510a --- /dev/null +++ b/src/components/pages/closing/skeleton/ClosingTableSkeleton.tsx @@ -0,0 +1,37 @@ +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; +import Table from '@/components/Table'; +import { Closing } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; + +const ClosingTableSkeleton = ({ + columns, + icon, + title, + subtitle, +}: { + columns: ColumnDef[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default ClosingTableSkeleton; diff --git a/src/components/pages/closing/skeleton/FinanceClosingSkeleton.tsx b/src/components/pages/closing/skeleton/FinanceClosingSkeleton.tsx new file mode 100644 index 00000000..1168710c --- /dev/null +++ b/src/components/pages/closing/skeleton/FinanceClosingSkeleton.tsx @@ -0,0 +1,40 @@ +import { Icon } from '@iconify/react'; +import Card from '@/components/Card'; +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; + +const FinanceClosingSkeleton = ({ + title = 'Data Keuangan Belum Tersedia', + subtitle = 'Tidak ada data keuangan untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( + +
+ + } + title={title} + description={subtitle} + /> +
+
+ ); +}; + +export default FinanceClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/HppExpeditionClosingSkeleton.tsx b/src/components/pages/closing/skeleton/HppExpeditionClosingSkeleton.tsx new file mode 100644 index 00000000..d9be9971 --- /dev/null +++ b/src/components/pages/closing/skeleton/HppExpeditionClosingSkeleton.tsx @@ -0,0 +1,29 @@ +import { Icon } from '@iconify/react'; +import ClosingTabSkeleton from './ClosingTabSkeleton'; +import { BaseExpeditionCost } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; + +const HppExpeditionClosingSkeleton = ({ + columns, + title = 'Data HPP Ekspedisi Belum Tersedia', + subtitle = 'Tidak ada data HPP ekspedisi untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + columns: ColumnDef[]; + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( + + columns={columns} + icon={ + + } + title={title} + subtitle={subtitle} + /> + ); +}; + +export default HppExpeditionClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/OverheadClosingSkeleton.tsx b/src/components/pages/closing/skeleton/OverheadClosingSkeleton.tsx new file mode 100644 index 00000000..7404f5d2 --- /dev/null +++ b/src/components/pages/closing/skeleton/OverheadClosingSkeleton.tsx @@ -0,0 +1,29 @@ +import { Icon } from '@iconify/react'; +import ClosingTabSkeleton from './ClosingTabSkeleton'; +import { Overhead } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; + +const OverheadClosingSkeleton = ({ + columns, + title = 'Data Overhead Belum Tersedia', + subtitle = 'Tidak ada data overhead untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + columns: ColumnDef[]; + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( + + columns={columns} + icon={ + + } + title={title} + subtitle={subtitle} + /> + ); +}; + +export default OverheadClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/ProductionDataClosingSkeleton.tsx b/src/components/pages/closing/skeleton/ProductionDataClosingSkeleton.tsx new file mode 100644 index 00000000..e0031394 --- /dev/null +++ b/src/components/pages/closing/skeleton/ProductionDataClosingSkeleton.tsx @@ -0,0 +1,33 @@ +import { Icon } from '@iconify/react'; +import DataStateSkeleton from '@/components/helper/skeleton/DataStateSkeleton'; + +const ProductionDataClosingSkeleton = ({ + title = 'Data Produksi Belum Tersedia', + subtitle = 'Tidak ada data produksi untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( +
+
+ + } + title={title} + description={subtitle} + /> +
+
+ ); +}; + +export default ProductionDataClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/SalesClosingSkeleton.tsx b/src/components/pages/closing/skeleton/SalesClosingSkeleton.tsx new file mode 100644 index 00000000..a9ec35aa --- /dev/null +++ b/src/components/pages/closing/skeleton/SalesClosingSkeleton.tsx @@ -0,0 +1,29 @@ +import { Icon } from '@iconify/react'; +import ClosingTabSkeleton from './ClosingTabSkeleton'; +import { BaseSales } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; + +const SalesClosingSkeleton = ({ + columns, + title = 'Data Penjualan Belum Tersedia', + subtitle = 'Tidak ada data penjualan untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + columns: ColumnDef[]; + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( + + columns={columns} + icon={ + + } + title={title} + subtitle={subtitle} + /> + ); +}; + +export default SalesClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/SapronakCalculationClosingSkeleton.tsx b/src/components/pages/closing/skeleton/SapronakCalculationClosingSkeleton.tsx new file mode 100644 index 00000000..97d4a56c --- /dev/null +++ b/src/components/pages/closing/skeleton/SapronakCalculationClosingSkeleton.tsx @@ -0,0 +1,29 @@ +import { Icon } from '@iconify/react'; +import ClosingTabSkeleton from './ClosingTabSkeleton'; +import { RowSapronakCalculation } from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; + +const SapronakCalculationClosingSkeleton = ({ + columns, + title = 'Data Perhitungan Sapronak Belum Tersedia', + subtitle = 'Tidak ada data perhitungan sapronak untuk periode ini.', + iconName = 'heroicons:chart-bar', +}: { + columns: ColumnDef[]; + title?: string; + subtitle?: string; + iconName?: string; +}) => { + return ( + + columns={columns} + icon={ + + } + title={title} + subtitle={subtitle} + /> + ); +}; + +export default SapronakCalculationClosingSkeleton; diff --git a/src/components/pages/closing/skeleton/SapronakClosingSkeleton.tsx b/src/components/pages/closing/skeleton/SapronakClosingSkeleton.tsx new file mode 100644 index 00000000..130cd846 --- /dev/null +++ b/src/components/pages/closing/skeleton/SapronakClosingSkeleton.tsx @@ -0,0 +1,40 @@ +import { Icon } from '@iconify/react'; +import ClosingTabSkeleton from './ClosingTabSkeleton'; +import { ColumnDef } from '@tanstack/react-table'; + +const SapronakClosingSkeleton = ({ + columns, + type = 'incoming', + title, + subtitle, + iconName = 'heroicons:chart-bar', +}: { + columns: ColumnDef[]; + type?: 'incoming' | 'outgoing'; + title?: string; + subtitle?: string; + iconName?: string; +}) => { + const defaultTitle = + type === 'incoming' + ? 'Data Sapronak Masuk Belum Tersedia' + : 'Data Sapronak Keluar Belum Tersedia'; + + const defaultSubtitle = + type === 'incoming' + ? 'Tidak ada data sapronak masuk untuk periode ini.' + : 'Tidak ada data sapronak keluar untuk periode ini.'; + + return ( + + columns={columns} + icon={ + + } + title={title || defaultTitle} + subtitle={subtitle || defaultSubtitle} + /> + ); +}; + +export default SapronakClosingSkeleton; diff --git a/src/components/pages/closing/tab/FinanceClosingTab.tsx b/src/components/pages/closing/tab/FinanceClosingTab.tsx new file mode 100644 index 00000000..53a5068b --- /dev/null +++ b/src/components/pages/closing/tab/FinanceClosingTab.tsx @@ -0,0 +1,13 @@ +import FinanceClosingTable from '@/components/pages/closing/table/FinanceClosingTable'; + +const FinanceClosingTab = ({ projectFlockId }: { projectFlockId: number }) => { + return ( +
+ {projectFlockId && ( + + )} +
+ ); +}; + +export default FinanceClosingTab; diff --git a/src/components/pages/closing/tab/HppExpeditionClosingTab.tsx b/src/components/pages/closing/tab/HppExpeditionClosingTab.tsx new file mode 100644 index 00000000..ad7f0ec1 --- /dev/null +++ b/src/components/pages/closing/tab/HppExpeditionClosingTab.tsx @@ -0,0 +1,19 @@ +import HppExpeditionClosingTable from '@/components/pages/closing/table/HppExpeditionClosingTable'; + +interface HppExpeditionClosingTabProps { + projectFlockId: number; +} + +const HppExpeditionClosingTab = ({ + projectFlockId, +}: HppExpeditionClosingTabProps) => { + return ( +
+ {projectFlockId && ( + + )} +
+ ); +}; + +export default HppExpeditionClosingTab; diff --git a/src/components/pages/closing/ClosingOverheadTabContent.tsx b/src/components/pages/closing/tab/OverheadClosingTab.tsx similarity index 67% rename from src/components/pages/closing/ClosingOverheadTabContent.tsx rename to src/components/pages/closing/tab/OverheadClosingTab.tsx index e6b0cb5a..85942a62 100644 --- a/src/components/pages/closing/ClosingOverheadTabContent.tsx +++ b/src/components/pages/closing/tab/OverheadClosingTab.tsx @@ -1,22 +1,22 @@ -import ClosingOverheadTable from '@/components/pages/closing/ClosingOverheadTable'; +import OverheadClosingTable from '@/components/pages/closing/table/OverheadClosingTable'; import { ClosingGeneralInformation } from '@/types/api/closing'; import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; -interface ClosingOverheadTabContentProps { +interface OverheadClosingTabProps { projectFlockId: number; generalInformation?: ClosingGeneralInformation; kandangData?: ProjectFlockKandang; } -const ClosingOverheadTabContent = ({ +const OverheadClosingTab = ({ projectFlockId, generalInformation, kandangData, -}: ClosingOverheadTabContentProps) => { +}: OverheadClosingTabProps) => { return (
{projectFlockId && ( - { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { data: productionData, isLoading } = useSWR( + `${ClosingApi.basePath}/${projectFlockId}/production-data?kandang_id=${kandangId ? `${kandangId}` : ''}`, + () => ClosingApi.getProductionData(projectFlockId, Number(kandangId)) + ); + + if (isLoading) { + return ; + } + + if (!productionData || !isResponseSuccess(productionData)) { + return ( + + ); + } + + const { purchase, sales, performance } = productionData.data; + + // Helper for consistent row styling + const DataRow = ({ + label, + value, + unit = '', + valueClassName = 'font-bold text-gray-800', + unitClassName = 'text-gray-500 w-12 text-right', + }: { + label: string; + value: string | number; + unit?: string; + valueClassName?: string; + unitClassName?: string; + }) => ( +
+ {label} +
+ {value} + {unit && {unit}} +
+
+ ); + + return ( +
+ +
+
+ {/* Left Column */} +
+ {/* Purchase Section */} +
+

+ Pembelian +

+
+ + + + + +
+
+ + {/* Sales Section */} +
+

+ Penjualan +

+
+ {/* Chicken Sales */} +
+ + + + +
+ + {/* Egg Sales (if available) */} + {sales.egg && ( + <> +
+
+ + + + +
+ + )} +
+
+
+ + {/* Divider Line (Absolute centered) */} +
+ + {/* Right Column */} +
+ {/* Performance Section */} +
+

+ Performance +

+
+ + + + + + {/* + */} + + + + + + + {/* Laying Specific Fields */} + {performance.hen_day_act !== undefined && ( + <> + + + + )} + + {performance.egg_mass !== undefined && ( + <> + + + + )} + + {performance.egg_weight !== undefined && ( + <> + + + + )} + + {performance.hen_housed_act !== undefined && ( + <> + + + + )} +
+
+
+
+
+ +
+ ); +}; + +export default ProductionDataClosingTab; diff --git a/src/components/pages/closing/tab/SalesClosingTab.tsx b/src/components/pages/closing/tab/SalesClosingTab.tsx new file mode 100644 index 00000000..ee343da0 --- /dev/null +++ b/src/components/pages/closing/tab/SalesClosingTab.tsx @@ -0,0 +1,15 @@ +import SalesClosingTable from '@/components/pages/closing/table/SalesClosingTable'; + +interface SalesClosingTabProps { + projectFlockId: number; +} + +const SalesClosingTab = ({ projectFlockId }: SalesClosingTabProps) => { + return ( +
+ {projectFlockId && } +
+ ); +}; + +export default SalesClosingTab; diff --git a/src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx b/src/components/pages/closing/tab/SapronakCalculationClosingTab.tsx similarity index 56% rename from src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx rename to src/components/pages/closing/tab/SapronakCalculationClosingTab.tsx index b8add15b..77a74d71 100644 --- a/src/components/pages/closing/ClosingSapronakCalculationTabContent.tsx +++ b/src/components/pages/closing/tab/SapronakCalculationClosingTab.tsx @@ -1,22 +1,22 @@ 'use client'; -import ClosingSapronakCalculationTable from '@/components/pages/closing/ClosingSapronakCalculationTable'; +import SapronakCalculationClosingTable from '@/components/pages/closing/table/SapronakCalculationClosingTable'; import { ClosingGeneralInformation } from '@/types/api/closing'; -interface ClosingSapronakCalculationTabContentProps { +interface SapronakCalculationClosingTabProps { projectFlockId?: number; closingGeneralInformation?: ClosingGeneralInformation; } -const ClosingSapronakCalculationTabContent = ({ +const SapronakCalculationClosingTab = ({ projectFlockId, closingGeneralInformation, -}: ClosingSapronakCalculationTabContentProps) => { +}: SapronakCalculationClosingTabProps) => { return (
{projectFlockId && ( <> - @@ -26,4 +26,4 @@ const ClosingSapronakCalculationTabContent = ({ ); }; -export default ClosingSapronakCalculationTabContent; +export default SapronakCalculationClosingTab; diff --git a/src/components/pages/closing/tab/SapronakClosingTab.tsx b/src/components/pages/closing/tab/SapronakClosingTab.tsx new file mode 100644 index 00000000..21bb3b3f --- /dev/null +++ b/src/components/pages/closing/tab/SapronakClosingTab.tsx @@ -0,0 +1,30 @@ +'use client'; + +import IncomingSapronaksTable from '@/components/pages/closing/table/sapronak/IncomingSapronaksTable'; +import OutgoingSapronaksTable from '@/components/pages/closing/table/sapronak/OutgoingSapronaksTable'; +import IncomingSapronaksSummaryTable from '@/components/pages/closing/table/sapronak/IncomingSapronaksSummaryTable'; +import OutgoingSapronaksSummaryTable from '@/components/pages/closing/table/sapronak/OutgoingSapronaksSummaryTable'; + +interface SapronakClosingTabProps { + projectFlockId?: number; +} + +const SapronakClosingTab = ({ projectFlockId }: SapronakClosingTabProps) => { + return ( +
+ {projectFlockId && ( + <> + + + + + + + + + )} +
+ ); +}; + +export default SapronakClosingTab; diff --git a/src/components/pages/closing/ClosingGeneralInformationTable.tsx b/src/components/pages/closing/table/ClosingGeneralInformationTable.tsx similarity index 100% rename from src/components/pages/closing/ClosingGeneralInformationTable.tsx rename to src/components/pages/closing/table/ClosingGeneralInformationTable.tsx diff --git a/src/components/pages/closing/table/FinanceClosingTable.tsx b/src/components/pages/closing/table/FinanceClosingTable.tsx new file mode 100644 index 00000000..760fbd04 --- /dev/null +++ b/src/components/pages/closing/table/FinanceClosingTable.tsx @@ -0,0 +1,507 @@ +import Alert from '@/components/Alert'; +import Card from '@/components/Card'; +import Table, { TABLE_DEFAULT_STYLING } from '@/components/Table'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { formatCurrency, formatTitleCase } from '@/lib/helper'; +import { ClosingApi } from '@/services/api/closing'; +import { HppItem, ProfitLossItem } from '@/types/api/closing'; +import { Icon } from '@iconify/react'; +import { useSearchParams } from 'next/navigation'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import FinanceClosingSkeleton from '@/components/pages/closing/skeleton/FinanceClosingSkeleton'; + +const FinanceClosingTable = ({ + projectFlockId, +}: { + projectFlockId: number; +}) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { data: finance, isLoading } = useSWR( + `/closing/finance/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`, + () => + ClosingApi.getFinance( + projectFlockId, + kandangId ? Number(kandangId) : undefined + ) + ); + + const hppTableData: HppItem[] = useMemo(() => { + if (isResponseSuccess(finance)) { + const customItems = { + label: 'HPP dan Pengeluaran', + code: 'custom_row', + } as HppItem; + const purchases = finance.data.hpp.items.filter( + (item) => item.category === 'purchase' + ); + const totalBudgeting = { + label: 'HPP dan Bahan Baku', + code: 'custom_row', + } as HppItem; + const overheads = finance.data.hpp.items.filter( + (item) => item.category === 'overhead' + ); + return [customItems, ...purchases, totalBudgeting, ...overheads]; + } + return []; + }, [finance]); + + const profitLossTableData: ProfitLossItem[] = useMemo(() => { + if (isResponseSuccess(finance)) { + const incomes = finance.data.profit_loss.items.filter( + (item) => item.type === 'income' + ); + const purchases = finance.data.profit_loss.items.filter( + (item) => item.type === 'purchase' + ); + const overheads = finance.data.profit_loss.items.filter( + (item) => item.type === 'overhead' + ); + const grossProfit = { + label: 'LABA RUGI BRUTO', + code: 'custom_row', + type: 'gross_profit', + rp_per_bird: + finance.data.profit_loss.summary.gross_profit.rp_per_bird ?? 0, + rp_per_kg: finance.data.profit_loss.summary.gross_profit.rp_per_kg ?? 0, + amount: finance.data.profit_loss.summary.gross_profit.amount ?? 0, + } as ProfitLossItem; + const subtotal = { + label: 'Subtotal', + code: 'custom_row', + type: 'subtotal', + rp_per_bird: + finance.data.profit_loss.summary.sub_total.rp_per_bird ?? 0, + rp_per_kg: finance.data.profit_loss.summary.sub_total.rp_per_kg ?? 0, + amount: finance.data.profit_loss.summary.sub_total.amount ?? 0, + } as ProfitLossItem; + return [...incomes, ...purchases, grossProfit, ...overheads, subtotal]; + } + return []; + }, [finance]); + + return ( +
+ {isLoading ? ( + + ) : !isResponseSuccess(finance) ? ( + + ) : ( + <> +
+ +
+ + + +
+

+ Laba Rugi Brutto +

+

+ {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.summary.gross_profit.amount + ) + : '-'} +

+
+
+
+ + +
+ + + +
+

+ Laba Rugi Netto +

+

+ {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.summary.net_profit.amount + ) + : '-'} +

+
+
+
+
+ +
+ + data={hppTableData} + isLoading={isLoading} + columns={[ + { + header: 'No.', + enableSorting: false, + accessorFn: (item, index) => { + if (item.code === 'custom_row') return '-'; + const dataRowsBefore = hppTableData + .slice(0, index) + .filter((row) => row.code !== 'custom_row').length; + return dataRowsBefore + 1; + }, + footer: () => { + return 'HPP'; + }, + }, + { + header: 'Jenis', + enableSorting: false, + accessorFn: (item) => formatTitleCase(item.label || '-'), + }, + { + header: 'Budgeting', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'budgeting_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_bird' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.budgeting + ?.rp_per_bird || 0 + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'budgeting_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'budgeting_rp_per_kg' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.budgeting + ?.rp_per_kg || 0 + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'budgeting_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.budgeting?.amount || 0), + footer: (props) => { + return props.column.id === 'budgeting_amount' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.budgeting?.amount || 0 + ) + : '-'; + }, + }, + ], + }, + { + header: 'Realization', + enableSorting: false, + columns: [ + { + header: 'Rp/Ekor', + id: 'realization_rp_per_bird', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_bird || 0), + footer: (props) => { + return props.column.id === + 'realization_rp_per_bird' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.realization + ?.rp_per_bird || 0 + ) + : '-'; + }, + }, + { + header: 'Rp/Kg', + id: 'realization_rp_per_kg', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.rp_per_kg || 0), + footer: (props) => { + return props.column.id === 'realization_rp_per_kg' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.realization + ?.rp_per_kg || 0 + ) + : '-'; + }, + }, + { + header: 'Jumlah (Rp)', + id: 'realization_amount', + enableSorting: false, + accessorFn: (item) => + formatCurrency(item.realization?.amount || 0), + footer: (props) => { + return props.column.id === 'realization_amount' && + isResponseSuccess(finance) + ? formatCurrency( + finance.data.hpp.summary?.realization?.amount || + 0 + ) + : '-'; + }, + }, + ], + }, + ]} + 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', + 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', + paginationClassName: 'hidden', + }} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.code === 'custom_row') { + return ( +
+ + + + ); + } + return null; + }} + renderFooter={isResponseSuccess(finance)} + /> + + + +
+ + data={profitLossTableData} + isLoading={isLoading} + columns={[ + { + header: 'Jenis', + enableSorting: false, + accessorFn: (item) => item.label, + cell: (item) => ( +
+ {formatTitleCase(item.row.original.label || '-')} +
+ ), + footer: () => ( +
LABA RUGI NETTO
+ ), + }, + { + header: 'Rp/Ekor', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_bird || 0), + footer: () => ( +
+ {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.summary.net_profit + .rp_per_bird || 0 + ) + : formatCurrency(0)} +
+ ), + }, + { + header: 'Rp/Kg', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.rp_per_kg || 0), + footer: () => ( +
+ {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.summary.net_profit + .rp_per_kg || 0 + ) + : formatCurrency(0)} +
+ ), + }, + { + header: 'Jumlah (Rp)', + enableSorting: false, + accessorFn: (item) => formatCurrency(item.amount || 0), + footer: () => ( +
+ {isResponseSuccess(finance) + ? formatCurrency( + finance.data.profit_loss.summary.net_profit + .amount || 0 + ) + : formatCurrency(0)} +
+ ), + }, + ]} + 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', + 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', + paginationClassName: 'hidden', + }} + renderCustomRow={(row) => { + const rowData = row.original; + if (rowData.code === 'custom_row') { + return ( +
+ + + + + + ); + } + return null; + }} + renderFooter={isResponseSuccess(finance)} + /> + + + + )} + + ); +}; + +export default FinanceClosingTable; diff --git a/src/components/pages/closing/table/HppExpeditionClosingTable.tsx b/src/components/pages/closing/table/HppExpeditionClosingTable.tsx new file mode 100644 index 00000000..5389e3d5 --- /dev/null +++ b/src/components/pages/closing/table/HppExpeditionClosingTable.tsx @@ -0,0 +1,147 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import Table from '@/components/Table'; +import Card from '@/components/Card'; +import { formatCurrency } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { BaseExpeditionCost } from '@/types/api/closing'; +import { ClosingApi } from '@/services/api/closing'; +import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import HppExpeditionClosingSkeleton from '@/components/pages/closing/skeleton/HppExpeditionClosingSkeleton'; + +interface HppExpeditionClosingTableProps { + projectFlockId: number; +} + +const HppExpeditionClosingTable = ({ + projectFlockId, +}: HppExpeditionClosingTableProps) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { data: hppExpedition, isLoading } = useSWR( + kandangId + ? `/closing/hpp-expedition/${projectFlockId}/${kandangId}` + : `/closing/hpp-expedition/${projectFlockId}`, + () => + kandangId + ? ClosingApi.getHppEkspedisiByKandang(projectFlockId, Number(kandangId)) + : ClosingApi.getHppEkspedisi(projectFlockId) + ); + + const costOfRevenueExpeditionData: BaseExpeditionCost[] = useMemo(() => { + if (isResponseSuccess(hppExpedition)) { + return hppExpedition.data.expedition_costs || []; + } + return []; + }, [hppExpedition]); + + const totals = useMemo(() => { + if (isResponseSuccess(hppExpedition)) { + return { + totalHpp: hppExpedition.data.total_hpp_amount || 0, + }; + } + return { + totalHpp: 0, + }; + }, [hppExpedition]); + + const costOfRevenueExpeditionColumns: ColumnDef[] = + useMemo( + () => [ + { + id: 'id', + accessorKey: 'id', + header: 'No', + cell: (props) => { + return
{props.row.index + 1}
; + }, + footer: () => ( +
+ Total HPP Ekspedisi +
+ ), + }, + { + id: 'expedition_vendor_name', + accessorKey: 'expedition_vendor_name', + header: 'Nama Ekspedisi', + cell: (props) => props.getValue() || '-', + }, + { + id: 'hpp_amount', + accessorKey: 'hpp_amount', + header: 'HPP Ekspedisi', + cell: (props) => { + const value = props.getValue() as number; + return
{formatCurrency(value)}
; + }, + footer: () => ( +
+ {formatCurrency(totals.totalHpp)} +
+ ), + }, + ], + [totals] + ); + + return ( +
+ + {isLoading ? ( + + ) : costOfRevenueExpeditionData.length === 0 ? ( + + ) : ( +
+
+ {formatTitleCase(rowData.label ?? '-')} +
+
+
+ {formatTitleCase(rowData.label ?? '-')} +
+
+
+ {formatCurrency(rowData.rp_per_bird ?? 0)} +
+
+
+ {formatCurrency(rowData.rp_per_kg ?? 0)} +
+
+
+ {formatCurrency(rowData.amount ?? 0)} +
+
0} + 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', + 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', + }} + /> + )} + + + ); +}; + +export default HppExpeditionClosingTable; diff --git a/src/components/pages/closing/ClosingOverheadTable.tsx b/src/components/pages/closing/table/OverheadClosingTable.tsx similarity index 73% rename from src/components/pages/closing/ClosingOverheadTable.tsx rename to src/components/pages/closing/table/OverheadClosingTable.tsx index a7a170eb..a6c31e6c 100644 --- a/src/components/pages/closing/ClosingOverheadTable.tsx +++ b/src/components/pages/closing/table/OverheadClosingTable.tsx @@ -14,18 +14,19 @@ import { ColumnDef } from '@tanstack/react-table'; import { useSearchParams } from 'next/navigation'; import { useMemo } from 'react'; import useSWR from 'swr'; +import OverheadClosingSkeleton from '@/components/pages/closing/skeleton/OverheadClosingSkeleton'; -interface ClosingOverheadTableProps { +interface OverheadClosingTableProps { projectFlockId: number; generalInformation?: ClosingGeneralInformation; kandangData?: ProjectFlockKandang; } -const ClosingOverheadTable = ({ +const OverheadClosingTable = ({ projectFlockId, generalInformation, kandangData, -}: ClosingOverheadTableProps) => { +}: OverheadClosingTableProps) => { const searchParams = useSearchParams(); const kandangId = searchParams.get('kandangId'); @@ -208,42 +209,84 @@ const ClosingOverheadTable = ({ ); return ( - <> +
- - data={ - kandangId - ? isResponseSuccess(overheadKandang) - ? (overheadKandang.data?.overheads ?? []) - : [] - : isResponseSuccess(overhead) - ? (overhead.data?.overheads ?? []) - : [] - } - columns={columns} - className={{ - containerClassName: 'my-4', - headerColumnClassName: cn( - TABLE_DEFAULT_STYLING.headerColumnClassName, - 'whitespace-nowrap' - ), - }} - isLoading={isLoadingOverhead} - renderFooter={ - isResponseSuccess(overhead) - ? overhead.data?.overheads.length > 0 - : false - } - /> - {kandangId && ( + {isLoadingOverhead ? ( + + ) : !isResponseSuccess(overhead) ? ( + + ) : kandangId && !isResponseSuccess(overheadKandang) ? ( + + ) : (!kandangId && overhead.data?.overheads.length === 0) || + (kandangId && + isResponseSuccess(overheadKandang) && + overheadKandang.data?.overheads.length === 0) ? ( + + ) : ( + + data={ + kandangId + ? isResponseSuccess(overheadKandang) + ? (overheadKandang.data?.overheads ?? []) + : [] + : isResponseSuccess(overhead) + ? (overhead.data?.overheads ?? []) + : [] + } + columns={columns} + 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: cn( + 'px-4 py-3 text-xs font-semibold text-gray-700 text-left border border-gray-200', + 'whitespace-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', + }} + isLoading={isLoadingOverhead} + renderFooter={ + isResponseSuccess(overhead) + ? overhead.data?.overheads.length > 0 + : false + } + /> + )} + {kandangId && !isLoadingOverhead && isResponseSuccess(overhead) && ( )} - +
); }; -export default ClosingOverheadTable; +export default OverheadClosingTable; diff --git a/src/components/pages/closing/sale/SalesReportTable.tsx b/src/components/pages/closing/table/SalesClosingTable.tsx similarity index 73% rename from src/components/pages/closing/sale/SalesReportTable.tsx rename to src/components/pages/closing/table/SalesClosingTable.tsx index 0632676b..5105d965 100644 --- a/src/components/pages/closing/sale/SalesReportTable.tsx +++ b/src/components/pages/closing/table/SalesClosingTable.tsx @@ -5,28 +5,47 @@ import { ColumnDef } from '@tanstack/react-table'; import Table from '@/components/Table'; import Card from '@/components/Card'; import { formatCurrency, formatNumber, formatDate } from '@/lib/helper'; -import { - BaseClosingSales, - BaseSales, - ClosingSalesSummary, -} from '@/types/api/closing'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { BaseSales, ClosingSalesSummary } from '@/types/api/closing'; import { Product } from '@/types/api/master-data/product'; import { Customer } from '@/types/api/master-data/customer'; import { Kandang } from '@/types/api/master-data/kandang'; +import { ClosingApi } from '@/services/api/closing'; +import { useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; +import SalesClosingSkeleton from '@/components/pages/closing/skeleton/SalesClosingSkeleton'; -interface SalesReportTableProps { - type?: 'detail'; - initialValues?: BaseClosingSales; +interface SalesClosingTableProps { + projectFlockId: number; } -const SalesReportTable = ({ initialValues }: SalesReportTableProps) => { +const SalesClosingTable = ({ projectFlockId }: SalesClosingTableProps) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { data: sales, isLoading } = useSWR( + kandangId + ? `/closing/sales/${projectFlockId}/${kandangId}` + : `/closing/sales/${projectFlockId}`, + () => + kandangId + ? ClosingApi.getPenjualanByKandang(projectFlockId, Number(kandangId)) + : ClosingApi.getPenjualan(projectFlockId) + ); + const salesData: BaseSales[] = useMemo(() => { - return initialValues?.sales || []; - }, [initialValues]); + if (isResponseSuccess(sales)) { + return sales.data.sales || []; + } + return []; + }, [sales]); const summary: ClosingSalesSummary | undefined = useMemo(() => { - return initialValues?.summary; - }, [initialValues]); + if (isResponseSuccess(sales)) { + return sales.data.summary; + } + return undefined; + }, [sales]); const totals = useMemo(() => { if (salesData.length === 0) { @@ -293,41 +312,55 @@ const SalesReportTable = ({ initialValues }: SalesReportTableProps) => { ); return ( - <> -
-
-

Penjualan

- + + {isLoading ? ( + + ) : salesData.length === 0 ? ( + + ) : ( +
0} className={{ - wrapper: 'w-full bg-base-100', - body: 'p-0', + 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', + 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', }} - > -
0} - className={{ - tableWrapperClassName: 'overflow-x-auto', - tableClassName: 'w-full table-auto text-sm', - headerColumnClassName: - 'px-4 py-3 text-xs font-semibold text-gray-500 whitespace-nowrap border-l border-l-gray-200 border-r border-r-gray-200 border-t border-t-gray-200 border-gray-200 border-b-0', - bodyRowClassName: - 'hover:bg-gray-50 transition-colors border-b border-gray-200 first:border-t first:border-t-gray-200 border-l border-l-gray-200 border-r 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', - }} - /> - - - - + /> + )} + + ); }; -export default SalesReportTable; +export default SalesClosingTable; diff --git a/src/components/pages/closing/table/SapronakCalculationClosingTable.tsx b/src/components/pages/closing/table/SapronakCalculationClosingTable.tsx new file mode 100644 index 00000000..6f1252fc --- /dev/null +++ b/src/components/pages/closing/table/SapronakCalculationClosingTable.tsx @@ -0,0 +1,359 @@ +'use client'; + +import Card from '@/components/Card'; + +import Table from '@/components/Table'; +import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; +import { + RowSapronakCalculation, + TotalSapronakCalculation, +} from '@/types/api/closing'; +import { ColumnDef } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import useSWR from 'swr'; +import { ClosingApi } from '@/services/api/closing'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { ClosingGeneralInformation } from '@/types/api/closing'; +import { useSearchParams } from 'next/navigation'; +import SapronakCalculationClosingSkeleton from '@/components/pages/closing/skeleton/SapronakCalculationClosingSkeleton'; + +interface SapronakCalculationClosingTableProps { + projectFlockId: number; + closingGeneralInformation?: ClosingGeneralInformation; +} + +const SapronakCalculationClosingTable = ({ + projectFlockId, + closingGeneralInformation, +}: SapronakCalculationClosingTableProps) => { + const searchParams = useSearchParams(); + const kandangId = searchParams.get('kandangId'); + + const { data: sapronakCalculation, isLoading } = useSWR( + `/closing/sapronak-calculation/${projectFlockId}${kandangId ? `/${kandangId}` : ''}`, + () => ClosingApi.getPerhitunganSapronak(projectFlockId, Number(kandangId)), + { + keepPreviousData: true, + } + ); + + // Helper function to create columns with footer support + const createColumns = ( + total?: TotalSapronakCalculation + ): ColumnDef[] => [ + { + header: 'Tanggal', + accessorKey: 'date', + cell: (props) => + props.row.original.date + ? formatDate(props.row.original.date, 'DD MMM YYYY') + : '-', + footer: 'Total', + }, + { + header: 'No. Referensi', + accessorKey: 'reference_number', + cell: (props) => (props.row.original.reference_number as string) || '-', + footer: '', + }, + { + header: 'QTY Masuk', + accessorKey: 'qty_in', + cell: (props) => + props.row.original.qty_in + ? formatNumber(props.row.original.qty_in as number) + : '0', + footer: total + ? () => ( +
+ {total?.qty_in ? formatNumber(total?.qty_in) : '0'} +
+ ) + : '', + }, + { + header: 'QTY Keluar', + accessorKey: 'qty_out', + cell: (props) => + props.row.original.qty_out + ? formatNumber(props.row.original.qty_out as number) + : '0', + footer: total + ? () => ( +
+ {total?.qty_out ? formatNumber(total?.qty_out) : '0'} +
+ ) + : '', + }, + { + header: 'QTY Pakai', + accessorKey: 'qty_used', + cell: (props) => + props.row.original.qty_used + ? formatNumber(props.row.original.qty_used as number) + : '0', + footer: total + ? () => ( +
+ {total?.qty_used ? formatNumber(total?.qty_used) : '0'} +
+ ) + : '', + }, + { + header: 'Uraian', + accessorKey: 'description', + cell: (props) => (props.row.original.description as string) || '-', + footer: '', + }, + { + header: 'Kategori Produk', + accessorKey: 'product_category', + cell: (props) => (props.row.original.product_category as string) || '-', + footer: '', + }, + { + header: 'Harga Beli/Qty (Rp)', + accessorKey: 'unit_price', + cell: (props) => + props.row.original.unit_price + ? formatCurrency(props.row.original.unit_price as number) + : '-', + footer: total + ? () => ( +
+ {total?.avg_unit_price + ? formatCurrency(total?.avg_unit_price) + : '-'} +
+ ) + : '', + }, + { + header: 'Total Harga (Rp)', + accessorKey: 'total_amount', + cell: (props) => + props.row.original.total_amount + ? formatCurrency(props.row.original.total_amount as number) + : '-', + footer: total + ? () => ( +
+ {total?.total_amount ? formatCurrency(total?.total_amount) : '-'} +
+ ) + : '', + }, + { + header: 'Keterangan', + accessorKey: 'notes', + cell: (props) => (props.row.original.notes as string) || '-', + footer: '', + }, + ]; + + // Memoize columns untuk setiap kategori + const docColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.doc?.total) + : createColumns(), + [sapronakCalculation] + ); + + const ovkColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.ovk?.total) + : createColumns(), + [sapronakCalculation] + ); + + const pakanColumns = useMemo( + () => + isResponseSuccess(sapronakCalculation) + ? createColumns(sapronakCalculation.data?.pakan?.total) + : createColumns(), + [sapronakCalculation] + ); + + return ( +
+ {/* Table DOC jika kategori Project Flock Growing */} + + {isLoading ? ( + + ) : isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.doc?.rows?.length === 0 ? ( + + ) : ( + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.doc?.rows ?? []) + : [] + } + columns={docColumns} + 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', + 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', + }} + renderFooter={ + isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.doc?.rows?.length > 0 + } + /> + )} + + + + {isLoading ? ( + + ) : isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.ovk?.rows?.length === 0 ? ( + + ) : ( + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.ovk?.rows ?? []) + : [] + } + columns={ovkColumns} + 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', + 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', + }} + renderFooter={ + isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.ovk?.rows?.length > 0 + } + /> + )} + + + + {isLoading ? ( + + ) : isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.pakan?.rows?.length === 0 ? ( + + ) : ( + + data={ + isResponseSuccess(sapronakCalculation) + ? (sapronakCalculation.data?.pakan?.rows ?? []) + : [] + } + columns={pakanColumns} + 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', + 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', + }} + renderFooter={ + isResponseSuccess(sapronakCalculation) && + sapronakCalculation.data?.pakan?.rows?.length > 0 + } + /> + )} + +
+ ); +}; + +export default SapronakCalculationClosingTable; diff --git a/src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx b/src/components/pages/closing/table/sapronak/IncomingSapronaksSummaryTable.tsx similarity index 50% rename from src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx rename to src/components/pages/closing/table/sapronak/IncomingSapronaksSummaryTable.tsx index 49e4f108..78773dbb 100644 --- a/src/components/pages/closing/ClosingIncomingSapronaksSummaryTable.tsx +++ b/src/components/pages/closing/table/sapronak/IncomingSapronaksSummaryTable.tsx @@ -1,20 +1,20 @@ 'use client'; -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; 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 Badge from '@/components/Badge'; import { cn, formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ClosingApi } from '@/services/api/closing'; import { ClosingIncomingSapronakSummary } from '@/types/api/closing'; +import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton'; interface ClosingIncomingSapronaksSummaryTableProps { projectFlockId: number; @@ -55,20 +55,60 @@ const ClosingIncomingSapronaksSummaryTable = ({ } ); - const [open, setOpen] = useState(true); - const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const incomingSapronaksColumns: ColumnDef[] = [ { - header: '#', + header: 'No', cell: (props) => props.row.index + 1, }, { accessorKey: 'category', header: 'Kategori', + cell: (props) => { + const categories = props.row.original.category + .split(' ') + .filter((cat) => cat.trim()); + const maxBadges = 4; + const visibleCategories = categories.slice(0, maxBadges); + const remainingCount = categories.length - maxBadges; + + return ( +
+ {visibleCategories.map((category, index) => ( + + {category.length > 12 + ? `${category.slice(0, 12)}...` + : category} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+ ); + }, }, { accessorKey: 'total_qty', @@ -78,10 +118,6 @@ const ClosingIncomingSapronaksSummaryTable = ({ }, ]; - const searchChangeHandler: ChangeEventHandler = (e) => { - updateFilter('search', e.target.value); - }; - // track sorting useEffect(() => { const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); @@ -93,44 +129,35 @@ const ClosingIncomingSapronaksSummaryTable = ({ } }, [sorting, updateFilter]); - useEffect(() => { - if (!open) { - setOpen( - isResponseSuccess(incomingSapronakSummaries) - ? incomingSapronakSummaries.data.length > 0 - : false - ); - } - }, [incomingSapronakSummaries, isResponseSuccess]); - return ( - - -
Ringkasan Sapronak Masuk
- - - - } - className='w-full!' - titleClassName='w-full p-0!' +
+ -
+ {isLoadingIncomingSapronakSummaries ? ( + + ) : isResponseSuccess(incomingSapronakSummaries) && + incomingSapronakSummaries.data.length === 0 ? ( + + ) : ( data={ isResponseSuccess(incomingSapronakSummaries) @@ -158,16 +185,21 @@ const ClosingIncomingSapronaksSummaryTable = ({ rowSelection={rowSelection} setRowSelection={setRowSelection} className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(incomingSapronakSummaries) && - incomingSapronakSummaries?.data?.length === 0, - }), + containerClassName: 'w-full mb-5!', + 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-b border-gray-200', + bodyRowClassName: 'hover:bg-gray-50 transition-colors', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', }} /> -
- -
+ )} + +
); }; diff --git a/src/components/pages/closing/ClosingIncomingSapronaksTable.tsx b/src/components/pages/closing/table/sapronak/IncomingSapronaksTable.tsx similarity index 51% rename from src/components/pages/closing/ClosingIncomingSapronaksTable.tsx rename to src/components/pages/closing/table/sapronak/IncomingSapronaksTable.tsx index 3d3a9d70..b0bd2744 100644 --- a/src/components/pages/closing/ClosingIncomingSapronaksTable.tsx +++ b/src/components/pages/closing/table/sapronak/IncomingSapronaksTable.tsx @@ -9,13 +9,14 @@ 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 Badge from '@/components/Badge'; import { cn, formatDate, formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ClosingApi } from '@/services/api/closing'; import { ClosingIncomingSapronak } from '@/types/api/closing'; +import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton'; interface ClosingIncomingSapronaksTableProps { projectFlockId: number; @@ -51,14 +52,12 @@ const ClosingIncomingSapronaksTable = ({ ClosingApi.getAllIncomingSapronakFetcher ); - const [open, setOpen] = useState(true); - const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const incomingSapronaksColumns: ColumnDef[] = [ { - header: '#', + header: 'No', cell: (props) => props.row.index + 1, }, { @@ -81,6 +80,48 @@ const ClosingIncomingSapronaksTable = ({ { accessorKey: 'product_category', header: 'Kategori Produk', + cell: (props) => { + const categories = props.row.original.product_category + .split(' ') + .filter((cat) => cat.trim()); + const maxBadges = 4; + const visibleCategories = categories.slice(0, maxBadges); + const remainingCount = categories.length - maxBadges; + + return ( +
+ {visibleCategories.map((category, index) => ( + + {category.length > 12 + ? `${category.slice(0, 12)}...` + : category} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+ ); + }, }, { accessorKey: 'source_warehouse', @@ -117,56 +158,59 @@ const ClosingIncomingSapronaksTable = ({ } }, [sorting, updateFilter]); - useEffect(() => { - if (!open) { - setOpen( - isResponseSuccess(incomingSapronaks) - ? incomingSapronaks.data.length > 0 - : false - ); - } - }, [incomingSapronaks, isResponseSuccess]); - return ( - - -
Sapronak Masuk
- - + +
+
+ + } + className={{ + wrapper: 'w-full min-w-24 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: + 'placeholder:font-semibold placeholder:text-base-content/50', + }} />
- } - className='w-full!' - titleClassName='w-full p-0!' - > -
-
-
- -
-
+
+ {isLoadingIncomingSapronaks ? ( + + ) : isResponseSuccess(incomingSapronaks) && + incomingSapronaks.data.length === 0 ? ( + + ) : ( data={ isResponseSuccess(incomingSapronaks) @@ -194,16 +238,21 @@ const ClosingIncomingSapronaksTable = ({ rowSelection={rowSelection} setRowSelection={setRowSelection} className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(incomingSapronaks) && - incomingSapronaks?.data?.length === 0, - }), + containerClassName: 'w-full mb-5!', + 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-b border-gray-200', + bodyRowClassName: 'hover:bg-gray-50 transition-colors', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', }} /> -
-
-
+ )} +
+ ); }; diff --git a/src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx b/src/components/pages/closing/table/sapronak/OutgoingSapronaksSummaryTable.tsx similarity index 50% rename from src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx rename to src/components/pages/closing/table/sapronak/OutgoingSapronaksSummaryTable.tsx index 42fcb588..b50b012b 100644 --- a/src/components/pages/closing/ClosingOutgoingSapronaksSummaryTable.tsx +++ b/src/components/pages/closing/table/sapronak/OutgoingSapronaksSummaryTable.tsx @@ -1,20 +1,20 @@ 'use client'; -import { ChangeEventHandler, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; 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 Badge from '@/components/Badge'; import { cn, formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ClosingApi } from '@/services/api/closing'; import { ClosingOutgoingSapronakSummary } from '@/types/api/closing'; +import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton'; interface ClosingOutgoingSapronaksSummaryTableProps { projectFlockId: number; @@ -55,20 +55,60 @@ const ClosingOutgoingSapronaksSummaryTable = ({ } ); - const [open, setOpen] = useState(true); - const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const outgoingSapronaksColumns: ColumnDef[] = [ { - header: '#', + header: 'No', cell: (props) => props.row.index + 1, }, { accessorKey: 'category', header: 'Kategori', + cell: (props) => { + const categories = props.row.original.category + .split(' ') + .filter((cat) => cat.trim()); + const maxBadges = 4; + const visibleCategories = categories.slice(0, maxBadges); + const remainingCount = categories.length - maxBadges; + + return ( +
+ {visibleCategories.map((category, index) => ( + + {category.length > 12 + ? `${category.slice(0, 12)}...` + : category} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+ ); + }, }, { accessorKey: 'total_qty', @@ -78,10 +118,6 @@ const ClosingOutgoingSapronaksSummaryTable = ({ }, ]; - const searchChangeHandler: ChangeEventHandler = (e) => { - updateFilter('search', e.target.value); - }; - // track sorting useEffect(() => { const isNameSorted = sorting.find((sortItem) => sortItem.id === 'name'); @@ -93,44 +129,35 @@ const ClosingOutgoingSapronaksSummaryTable = ({ } }, [sorting, updateFilter]); - useEffect(() => { - if (!open) { - setOpen( - isResponseSuccess(outgoingSapronakSummaries) - ? outgoingSapronakSummaries.data.length > 0 - : false - ); - } - }, [outgoingSapronakSummaries, isResponseSuccess]); - return ( - - -
Ringkasan Sapronak Keluar
- - - - } - className='w-full!' - titleClassName='w-full p-0!' +
+ -
+ {isLoadingOutgoingSapronakSummaries ? ( + + ) : isResponseSuccess(outgoingSapronakSummaries) && + outgoingSapronakSummaries.data.length === 0 ? ( + + ) : ( data={ isResponseSuccess(outgoingSapronakSummaries) @@ -158,16 +185,21 @@ const ClosingOutgoingSapronaksSummaryTable = ({ rowSelection={rowSelection} setRowSelection={setRowSelection} className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(outgoingSapronakSummaries) && - outgoingSapronakSummaries?.data?.length === 0, - }), + containerClassName: 'w-full mb-5!', + 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-b border-gray-200', + bodyRowClassName: 'hover:bg-gray-50 transition-colors', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', }} /> -
- -
+ )} + +
); }; diff --git a/src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx b/src/components/pages/closing/table/sapronak/OutgoingSapronaksTable.tsx similarity index 50% rename from src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx rename to src/components/pages/closing/table/sapronak/OutgoingSapronaksTable.tsx index acbbc52d..23e3e8b0 100644 --- a/src/components/pages/closing/ClosingOutgoingSapronaksTable.tsx +++ b/src/components/pages/closing/table/sapronak/OutgoingSapronaksTable.tsx @@ -9,13 +9,16 @@ 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 Badge from '@/components/Badge'; -import { cn, formatDate, formatNumber } from '@/lib/helper'; +import { cn } from '@/lib/helper'; + +import { formatDate, formatNumber } from '@/lib/helper'; import { isResponseSuccess } from '@/lib/api-helper'; import { useTableFilter } from '@/services/hooks/useTableFilter'; import { ClosingApi } from '@/services/api/closing'; import { ClosingOutgoingSapronak } from '@/types/api/closing'; +import SapronakClosingSkeleton from '@/components/pages/closing/skeleton/SapronakClosingSkeleton'; interface ClosingOutgoingSapronaksTableProps { projectFlockId: number; @@ -51,14 +54,12 @@ const ClosingOutgoingSapronaksTable = ({ ClosingApi.getAllOutgoingSapronakFetcher ); - const [open, setOpen] = useState(true); - const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState>({}); const outgoingSapronaksColumns: ColumnDef[] = [ { - header: '#', + header: 'No', cell: (props) => props.row.index + 1, }, { @@ -81,6 +82,48 @@ const ClosingOutgoingSapronaksTable = ({ { accessorKey: 'product_category', header: 'Kategori Produk', + cell: (props) => { + const categories = props.row.original.product_category + .split(' ') + .filter((cat) => cat.trim()); + const maxBadges = 4; + const visibleCategories = categories.slice(0, maxBadges); + const remainingCount = categories.length - maxBadges; + + return ( +
+ {visibleCategories.map((category, index) => ( + + {category.length > 12 + ? `${category.slice(0, 12)}...` + : category} + + ))} + {remainingCount > 0 && ( + + +{remainingCount} + + )} +
+ ); + }, }, { accessorKey: 'source_warehouse', @@ -117,56 +160,59 @@ const ClosingOutgoingSapronaksTable = ({ } }, [sorting, updateFilter]); - useEffect(() => { - if (!open) { - setOpen( - isResponseSuccess(outgoingSapronaks) - ? outgoingSapronaks.data.length > 0 - : false - ); - } - }, [outgoingSapronaks, isResponseSuccess]); - return ( - - -
Sapronak Keluar
- - + +
+
+ + } + className={{ + wrapper: 'w-full min-w-24 max-w-3xs', + inputWrapper: 'rounded-xl! shadow-button-soft', + input: + 'placeholder:font-semibold placeholder:text-base-content/50', + }} />
- } - className='w-full!' - titleClassName='w-full p-0!' - > -
-
-
- -
-
+
+ {isLoadingOutgoingSapronaks ? ( + + ) : isResponseSuccess(outgoingSapronaks) && + outgoingSapronaks.data.length === 0 ? ( + + ) : ( data={ isResponseSuccess(outgoingSapronaks) @@ -194,16 +240,21 @@ const ClosingOutgoingSapronaksTable = ({ rowSelection={rowSelection} setRowSelection={setRowSelection} className={{ - containerClassName: cn({ - 'w-full mb-20': - isResponseSuccess(outgoingSapronaks) && - outgoingSapronaks?.data?.length === 0, - }), + containerClassName: 'w-full mb-5!', + 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-b border-gray-200', + bodyRowClassName: 'hover:bg-gray-50 transition-colors', + bodyColumnClassName: + 'px-4 py-3 text-xs text-gray-900 whitespace-nowrap', }} /> -
-
-
+ )} +
+ ); }; 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', 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 && ( + + )} )} diff --git a/src/components/pages/production/chickin/form/ChickinForm.tsx b/src/components/pages/production/chickin/form/ChickinForm.tsx index bd3ff57c..b9c73934 100644 --- a/src/components/pages/production/chickin/form/ChickinForm.tsx +++ b/src/components/pages/production/chickin/form/ChickinForm.tsx @@ -16,6 +16,7 @@ import ChickinLogsView from '@/components/pages/production/chickin/form/tabs/Chi import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import { Icon } from '@iconify/react'; import Badge from '@/components/Badge'; +import StatusBadge from '@/components/helper/StatusBadge'; import { CHICKINS_APPROVAL_LINE } from '@/config/approval-line'; import RequirePermission from '@/components/helper/RequirePermission'; import { BaseApproval } from '@/types/api/api-general'; @@ -53,135 +54,126 @@ const ChickinFormKandang = ({ }; return ( -
+
+ {/* Header */} - {/* Informasi Kandang */} -
-
-

Informasi Kandang

+ {approvals && !approvalsLoading && ( + + )} - {approvals && !approvalsLoading && ( -
- -
- )} + {/* Informasi Kandang */} +
+

+ Informasi Kandang +

{/* Badge Row */}
- - {' '} - Aktif - +
- - - {` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`} - + text={` Kapasitas ${formatNumber(initialValues.kandang.capacity)} Ekor`} + className={{ badge: 'w-fit text-nowrap' }} + />
- {/* Information Grid */} -
- {/* Area */} -
- Area + {/* Information Card */} + +
+
+
+ {' '} + Area +
+
+ {initialValues.project_flock.area.name} +
+
+
+
+ {' '} + Lokasi +
+
+ {initialValues.project_flock?.location.name} +
+
+
+
+ {' '} + Kandang +
+
+ {initialValues.kandang.name} +
+
+
+
+ {' '} + Jumlah DOC +
+
+ {formatNumber( + initialValues.chickins?.reduce( + (total, chickin) => total + chickin.usage_qty, + 0 + ) ?? 0 + )}{' '} + Ekor +
+
-
- {initialValues.project_flock.area.name} -
- - {/* Lokasi */} -
- Lokasi -
-
- {initialValues.project_flock?.location.name} -
- - {/* Kandang */} -
- Kandang -
-
{initialValues.kandang.name}
- - {/* Jumlah DOC */} -
- Jumlah DOC -
-
- {formatNumber( - initialValues.chickins?.reduce( - (total, chickin) => total + chickin.usage_qty, - 0 - ) ?? 0 - )}{' '} - Ekor -
-
+
-
-
-

Informasi Chick In

+ {/* Informasi Chick In */} +
+

+ Informasi Chick In +

{/* Badge Row */}
- - {' '} - Perlu Chick In ({initialValues.available_qtys?.length ?? 0}) - +
- setOpenChickin(!openChickin)} - > - {`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`} - - + text={ + <> + {`Riwayat Chick In ${formatNumber(initialValues.chickins?.length ?? 0)}`} + + + } + className={{ badge: 'w-fit text-nowrap cursor-pointer' }} + />
{openChickin && ( @@ -198,7 +190,7 @@ const ChickinFormKandang = ({ afterSubmit={afterSubmitFormChickin} /> -
+ ); }; diff --git a/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx b/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx index e800ee68..bdffda33 100644 --- a/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx +++ b/src/components/pages/production/chickin/form/tabs/ChickLogsView.tsx @@ -2,8 +2,6 @@ import Alert from '@/components/Alert'; import Button from '@/components/Button'; import Card from '@/components/Card'; import RequirePermission from '@/components/helper/RequirePermission'; -import { useModal } from '@/components/Modal'; -import ConfirmationModalWithNotes from '@/components/modal/ConfirmationModalWithNotes'; import PillBadge from '@/components/PillBadge'; import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; import { formatDate, formatNumber } from '@/lib/helper'; @@ -13,6 +11,7 @@ import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandan import { Icon } from '@iconify/react'; import { useState } from 'react'; import toast from 'react-hot-toast'; +import { useChickinStore } from '@/stores/production/chickin/chickin.store'; const ChickinLogsView = ({ initialValues, @@ -23,32 +22,26 @@ const ChickinLogsView = ({ afterSubmit?: () => void; rawDataApprovals: BaseApproval[]; }) => { - const confirmModal = useModal(); - const [isApproveLoading, setIsApproveLoading] = useState(false); const [chickinErrorMessage, setChickinErrorMessage] = useState(''); + const { openChickinApproveModal } = useChickinStore(); const handleClickApprove = () => { - confirmModal.openModal(); - }; - - const confirmationModalApproveClickHandler = async (notes?: string) => { - setChickinErrorMessage(''); - setIsApproveLoading(true); - const approveChickinRes = await ChickinApi.singleApproval( - initialValues?.id as number, - 'APPROVED', - notes - ); - if (isResponseSuccess(approveChickinRes)) { - toast.success(approveChickinRes?.message as string); - } - if (isResponseError(approveChickinRes)) { - toast.error(approveChickinRes?.message as string); - setChickinErrorMessage(approveChickinRes?.message as string); - } - confirmModal.closeModal(); - setIsApproveLoading(false); - afterSubmit && afterSubmit(); + openChickinApproveModal(initialValues, async (notes?: string) => { + setChickinErrorMessage(''); + const approveChickinRes = await ChickinApi.singleApproval( + initialValues?.id as number, + 'APPROVED', + notes + ); + if (isResponseSuccess(approveChickinRes)) { + toast.success(approveChickinRes?.message as string); + } + if (isResponseError(approveChickinRes)) { + toast.error(approveChickinRes?.message as string); + setChickinErrorMessage(approveChickinRes?.message as string); + } + afterSubmit && afterSubmit(); + }); }; return ( @@ -83,7 +76,7 @@ const ChickinLogsView = ({ key={chickin.id || index} variant='bordered' className={{ - wrapper: 'w-full', + wrapper: 'w-full mt-3', body: 'p-3', }} > @@ -176,23 +169,6 @@ const ChickinLogsView = ({ )} - - { - confirmationModalApproveClickHandler(notes); - }, - isLoading: isApproveLoading, - }} - /> ); }; diff --git a/src/components/pages/production/project-flock/ProjectFlockTable.tsx b/src/components/pages/production/project-flock/ProjectFlockTable.tsx index 4085bc56..040948ff 100644 --- a/src/components/pages/production/project-flock/ProjectFlockTable.tsx +++ b/src/components/pages/production/project-flock/ProjectFlockTable.tsx @@ -34,8 +34,9 @@ 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'; +import { useChickinStore } from '@/stores/production/chickin/chickin.store'; const RowOptionsMenu = ({ props, @@ -193,6 +194,7 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const deleteModal = useModal(); const confirmModal = useModal(); const successModal = useModal(); + const chickinApproveModal = useModal(); const [approvalAction, setApprovalAction] = useState<'APPROVED' | 'REJECTED'>( 'APPROVED' ); @@ -200,6 +202,13 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { const [isApproveLoading, setIsApproveLoading] = useState(false); const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = useState(false); + const { + isChickinApproveModalOpen, + isChickinApproveLoading, + chickinApproveCallback, + closeChickinApproveModal, + setChickinApproveLoading, + } = useChickinStore(); // ===== Fetch Data ===== const { @@ -271,7 +280,11 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { ); if (isResponseSuccess(approveProjectFlockRes)) { - toast.success('Project Flock berhasil di-approve!'); + const successMessage = + approvalAction === 'APPROVED' + ? 'Project Flock berhasil di-approve!' + : 'Project Flock berhasil di-reject!'; + toast.success(successMessage); confirmModal.closeModal(); } if (isResponseError(approveProjectFlockRes)) { @@ -288,6 +301,14 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { refreshProjectFlocks(); }, [refresh]); + useEffect(() => { + if (isChickinApproveModalOpen) { + chickinApproveModal.openModal(); + } else { + chickinApproveModal.closeModal(); + } + }, [isChickinApproveModalOpen, chickinApproveModal]); + useEffect(() => { if (isSuccess) { successModal.openModal(); @@ -384,7 +405,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 +425,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 +875,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', { @@ -954,6 +991,40 @@ const ProjectFlockTable = ({ refresh }: { refresh?: () => void }) => { onClose={handleSuccessModalClose} secondaryButton={undefined} /> + + {/* Chickin Approval Modal */} + { + closeChickinApproveModal(); + chickinApproveModal.closeModal(); + }, + }} + primaryButton={{ + text: 'Ya', + color: 'success', + onClick: async (notes) => { + if (chickinApproveCallback) { + setChickinApproveLoading(true); + try { + await chickinApproveCallback(notes); + } finally { + setChickinApproveLoading(false); + closeChickinApproveModal(); + chickinApproveModal.closeModal(); + } + } + }, + isLoading: isChickinApproveLoading, + }} + /> ); }; diff --git a/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx index aab21172..f963a793 100644 --- a/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx +++ b/src/components/pages/production/project-flock/closing/ProjectFlockClosingForm.tsx @@ -1,10 +1,12 @@ 'use client'; import Button from '@/components/Button'; +import Card from '@/components/Card'; import DrawerHeader from '@/components/helper/drawer/DrawerHeader'; import Table from '@/components/Table'; import Badge from '@/components/Badge'; -import { cn, formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; +import StatusBadge from '@/components/helper/StatusBadge'; +import { formatDate, formatNumber, formatTitleCase } from '@/lib/helper'; import { ProjectFlock } from '@/types/api/production/project-flock'; import { ClosingExpense, @@ -20,9 +22,28 @@ import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { useRouter } from 'next/navigation'; -import { ProductWarehouse } from '@/types/api/inventory/product-warehouse'; import { ApprovalApi } from '@/services/api/approval'; import RequirePermission from '@/components/helper/RequirePermission'; +import { Color } from '@/types/theme'; + +const getExpenseStatusBadgeColor = (step: number): Color => { + switch (step) { + case 1: + return 'neutral'; + case 2: + return 'info'; + case 3: + return 'warning'; + case 4: + return 'error'; + case 5: + return 'warning'; + case 6: + return 'success'; + default: + return 'neutral'; + } +}; const ProjectFlockClosingForm = ({ projectFlock, @@ -49,6 +70,10 @@ const ProjectFlockClosingForm = ({ ) ); + const isKandangClosed = useMemo(() => { + return projectFlockKandang.kandang?.status === 'NON_ACTIVE'; + }, [projectFlockKandang]); + const isCanClose = useMemo(() => { return isResponseSuccess(projectFlockKandangApprovals) ? projectFlockKandangApprovals?.data?.[0]?.step_number <= 2 @@ -60,8 +85,10 @@ const ProjectFlockClosingForm = ({ const deleteProjectFlockRes = await ProjectFlockKandangApi.closing( projectFlockKandang?.id as number, { - closed_date: isCanClose ? formatDate(new Date(), 'YYYY-MM-DD') : '', - action: isCanClose ? 'close' : 'unclose', + closed_date: !isKandangClosed + ? formatDate(new Date(), 'YYYY-MM-DD') + : '', + action: !isKandangClosed ? 'close' : 'unclose', } ); @@ -78,160 +105,167 @@ const ProjectFlockClosingForm = ({ closeModal.closeModal(); }; - const errorStock = useMemo(() => { - return isResponseSuccess(closingData) - ? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0) - : true; - }, [closingData]); + // const errorStock = useMemo(() => { + // return isResponseSuccess(closingData) + // ? closingData?.data?.stock_remaining.every((stock) => stock.quantity > 0) + // : true; + // }, [closingData]); - const errorExpense = useMemo(() => { - return isResponseSuccess(closingData) - ? closingData?.data?.expenses.every((expense) => expense.step < 5) - : true; - }, [closingData]); + // const errorExpense = useMemo(() => { + // return isResponseSuccess(closingData) + // ? closingData?.data?.expenses.every((expense) => expense.step < 5) + // : true; + // }, [closingData]); const isCanCloseValid = true; return ( <> -
+
+ {/* Header */} + leftIconClassName='hover:text-gray-400' + subtitle={isKandangClosed ? 'Unclose Flock' : 'Close Flock'} + className='sticky top-0 z-10 bg-base-100' + /> {/* Informasi Kandang */} -
-
-

Informasi Kandang

+
+

+ Informasi Kandang +

{/* Badge Row */}
- - {' '} - Aktif - +
- - - {` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`} - + text={` Kapasitas ${formatNumber(projectFlockKandang.kandang?.capacity)} Ekor`} + className={{ badge: 'w-fit text-nowrap' }} + />
- {/* Information Grid */} -
- {/* Area */} -
- Area + {/* Information Card */} + +
+
+
+ {' '} + Area +
+
+ {projectFlock.area?.name} +
+
+
+
+ {' '} + Lokasi +
+
+ {projectFlock.location?.name} +
+
+
+
+ {' '} + Kandang +
+
+ {projectFlockKandang.kandang?.name} +
+
+
+
+ {' '} + Jumlah DOC +
+
+ {formatNumber( + projectFlockKandang.chickins?.reduce( + (total, chickin) => total + chickin.usage_qty, + 0 + ) ?? 0 + )}{' '} + Ekor +
+
-
{projectFlock.area?.name}
- - {/* Lokasi */} -
- Lokasi -
-
{projectFlock.location?.name}
- - {/* Kandang */} -
- Kandang -
-
- {projectFlockKandang.kandang?.name} -
- - {/* Jumlah DOC */} -
- Jumlah - DOC -
-
- {formatNumber( - projectFlockKandang.chickins?.reduce( - (total, chickin) => total + chickin.usage_qty, - 0 - ) ?? 0 - )}{' '} - Ekor -
-
+
{/* Table Biaya */} -
-
-

Biaya

+
+

+ Biaya +

data={ isResponseSuccess(closingData) ? closingData.data?.expenses : [] } columns={[ + { + header: 'Ref Number', + accessorKey: 'reference_number', + cell(props) { + return props.row.original.reference_number || '-'; + }, + }, { header: 'PO Number', accessorKey: 'po_number', + cell(props) { + return props.row.original.po_number || '-'; + }, }, { header: 'Total', accessorKey: 'total', + cell(props) { + return formatNumber(props.row.original.total); + }, }, { header: 'Status', accessorKey: 'status', cell(props) { return ( - - {formatTitleCase(props.row.original.step_name)} - + /> ); }, }, ]} className={{ - containerClassName: cn('my-4'), - tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120', - tableClassName: 'font-inter w-full table-sm min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', + containerClassName: 'mb-0', + tableWrapperClassName: 'overflow-x-auto max-w-120', + tableClassName: 'font-inter w-full table-sm', + headerRowClassName: 'border-b border-base-content/10', headerColumnClassName: 'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', + bodyRowClassName: 'border-b border-base-content/10', bodyColumnClassName: 'px-3 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', }} /> {/* {errorExpense && ( @@ -242,9 +276,10 @@ const ProjectFlockClosingForm = ({
{/* Table Persediaan Gudang */} -
-
-

Persediaan Gudang

+
+

+ Persediaan Gudang +

data={ isResponseSuccess(closingData) @@ -263,6 +298,9 @@ const ProjectFlockClosingForm = ({ { header: 'Quantity', accessorKey: 'quantity', + cell(props) { + return formatNumber(props.row.original.quantity); + }, }, { header: 'UOM', @@ -270,16 +308,15 @@ const ProjectFlockClosingForm = ({ }, ]} className={{ - containerClassName: cn('my-4'), - tableWrapperClassName: 'overflow-x-auto min-h-full! max-w-120', - tableClassName: 'font-inter w-full table-sm min-h-full!', - headerRowClassName: 'border-b border-b-gray-200', + containerClassName: 'mb-0', + tableWrapperClassName: 'overflow-x-auto max-w-120', + tableClassName: 'font-inter w-full table-sm', + headerRowClassName: 'border-b border-base-content/10', headerColumnClassName: 'px-3 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end', - bodyRowClassName: 'border-b border-b-gray-200', + bodyRowClassName: 'border-b border-base-content/10', bodyColumnClassName: 'px-3 py-3 last:flex last:flex-row last:justify-end', - paginationClassName: 'hidden', }} /> {/* {errorStock && ( @@ -289,17 +326,24 @@ const ProjectFlockClosingForm = ({ )} */}
-
+
@@ -308,7 +352,7 @@ const ProjectFlockClosingForm = ({ ref={closeModal.ref} type='error' text={ - isCanClose + !isKandangClosed ? 'Apakah kamu yakin ingin mengakhiri project ini ? *Pastikan persediaan produk di gudang terkait sudah kosong, dan BOP sudah selesai' : 'Apakah kamu yakin ingin membuka kembali project ini ? *Project ini akan kembali ke status aktif' } @@ -322,7 +366,7 @@ const ProjectFlockClosingForm = ({ onClick: confirmationModalCloseClickHandler, }} /> -
+ ); }; diff --git a/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx index 47491dfa..db3d45aa 100644 --- a/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx +++ b/src/components/pages/production/project-flock/detail/ProjectFlockDetail.tsx @@ -226,15 +226,37 @@ const ProjectFlockDetail = ({

- Kandang Aktif + Kandang

-
- +
+ {projectFlock.kandangs?.filter( + (kandang) => kandang.status !== 'NON_ACTIVE' + ).length > 0 && ( + kandang.status !== 'NON_ACTIVE' + ).length ?? 0 + })`} + className={{ badge: 'w-fit' }} + /> + )} + + {projectFlock.kandangs?.filter( + (kandang) => kandang.status === 'NON_ACTIVE' + ).length > 0 && ( + kandang.status === 'NON_ACTIVE' + ).length ?? 0 + })`} + className={{ badge: 'w-fit' }} + /> + )} } - className={{ badge: 'w-fit text-nowrap cursor-pointer' }} + className={{ badge: 'w-fit cursor-pointer' }} />
@@ -355,43 +377,53 @@ const ProjectFlockDetail = ({ disabled={projectFlock?.approval?.step_number == 1} />
- - Kapasitas {kandang?.capacity} Ekor - + Kapasitas {kandang?.capacity} Ekor} + className={{ badge: 'w-fit text-nowrap' }} + />
))} - +
+ +
-
- - - - - + + + + )} - Close + {selectedKandang?.status === 'NON_ACTIVE' ? ( + <> + Unclose + + ) : ( + <> + Close + + )} 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/recording/RecordingTable.tsx b/src/components/pages/production/recording/RecordingTable.tsx index a67f44f9..65e658f9 100644 --- a/src/components/pages/production/recording/RecordingTable.tsx +++ b/src/components/pages/production/recording/RecordingTable.tsx @@ -38,38 +38,26 @@ import { Color } from '@/types/theme'; // ===== STATUS BADGE UTILITIES ===== const statusTextMap: Record = { APPROVED: 'Disetujui', - Disetujui: 'Disetujui', REJECTED: 'Ditolak', - Ditolak: 'Ditolak', - CREATED: 'Dibuat', + CREATED: 'Pengajuan', UPDATED: 'Diperbarui', }; const getStatusText = (status: string): string => { - return statusTextMap[status] || status; + const normalizedStatus = status.toUpperCase(); + return statusTextMap[normalizedStatus] || status; }; const statusBadgeColorMap: Record = { APPROVED: 'success', - Disetujui: 'success', - approved: 'success', - disetujui: 'success', REJECTED: 'error', - Ditolak: 'error', - rejected: 'error', - ditolak: 'error', CREATED: 'neutral', - Dibuat: 'neutral', - created: 'neutral', - dibuat: 'neutral', UPDATED: 'warning', - Diperbarui: 'warning', - updated: 'warning', - diperbarui: 'warning', }; const getStatusBadgeColor = (status: string): Color => { - return statusBadgeColorMap[status] || 'neutral'; + const normalizedStatus = status.toUpperCase(); + return statusBadgeColorMap[normalizedStatus] || 'neutral'; }; const RowOptionsMenu = ({ @@ -852,8 +840,7 @@ const RecordingTable = () => { const status = approval.action; const statusColor = getStatusBadgeColor(status); - - const statusText = approval.step_name || getStatusText(status); + const statusText = getStatusText(status); return ( { } = useSelect(ProductWarehouseApi.basePath, 'id', 'product.name', '', { location_id: depletionProductsLocationId, kandang_id: depletionProductsKandangId, + type: 'AYAM', }); const today = new Date().toISOString().split('T')[0]; @@ -784,18 +785,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { if (isResponseSuccess(depletionProductsData) && selectedKandang) { const data = depletionProductsData.data as unknown as ProductWarehouse[]; data.forEach((product) => { - const productName = product.product.name; - - if ( - productName.toLowerCase().includes('culling') || - productName.toLowerCase().includes('mati') || - productName.toLowerCase().includes('afkir') - ) { - options.push({ - value: product.id, - label: product.product.name, - }); - } + options.push({ + value: product.id, + label: product.product.name, + }); }); } 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/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..3e13c539 --- /dev/null +++ b/src/components/pages/report/expense/skeleton/ReportExpenseSkeleton.tsx @@ -0,0 +1,49 @@ +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; diff --git a/src/components/pages/report/finance/FinanceTabs.tsx b/src/components/pages/report/finance/FinanceTabs.tsx index ffb0d3f1..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/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/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..1c546058 100644 --- a/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx +++ b/src/components/pages/report/finance/tab/CustomerPaymentTab.tsx @@ -2,17 +2,22 @@ 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'; 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'; +import { + formatCurrency, + formatDate, + formatNumber, + formatTitleCase, + cn, +} from '@/lib/helper'; import { CustomerPaymentReport, CustomerPaymentSummary, @@ -20,20 +25,31 @@ 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 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 { 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'; 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 +62,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 +76,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { [] ); - const [filterByType, setFilterByType] = useState< - (typeof dataTypeOptions)[0] | null - >(null); - const { options: customerOptions, setInputValue: setCustomerInputValue, @@ -92,108 +83,67 @@ 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 getPaymentStatusColor = (notes: string) => { - const normalizedValue = notes.toLowerCase(); - - if (normalizedValue === 'lunas') { - return 'bg-info/10 text-black border-info'; - } - - if (normalizedValue.includes('belum')) { - return 'bg-warning/10 text-black border-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(' '); - }; - - // ===== FILTER HANDLERS ===== - const handleFilterModalOpen = useCallback(() => { - setFilterCustomer(appliedFilterCustomer); - // setFilterSales(appliedFilterSales); - setFilterByType(appliedFilterByType); - setFilterStartDate(appliedFilterStartDate); - setFilterEndDate(appliedFilterEndDate); + const handleFilterModalOpen = () => { filterModal.openModal(); - }, [ - filterModal, - appliedFilterCustomer, - appliedFilterByType, - appliedFilterStartDate, - appliedFilterEndDate, - ]); + formik.validateForm(); + }; - const handleResetFilters = useCallback(() => { - setIsSubmitted(false); - setFilterCustomer([]); - setFilterByType(null); - setFilterStartDate(''); - setFilterEndDate(''); - setAppliedFilterCustomer([]); - setAppliedFilterByType(null); - setAppliedFilterStartDate(''); - setAppliedFilterEndDate(''); - setHasDateError(false); - if (dateErrorShown) { - toast.dismiss(); - setDateErrorShown(false); + // ===== 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 getPaymentStatusBadgeColor = (notes: string): Color => { + const normalizedValue = notes.toLowerCase(); + + if (normalizedValue === 'lunas') { + return 'primary'; } - }, [dateErrorShown]); - const handleApplyFilters = useCallback(() => { - setAppliedFilterCustomer(filterCustomer); - setAppliedFilterByType(filterByType); - setAppliedFilterStartDate(filterStartDate); - setAppliedFilterEndDate(filterEndDate); - setIsSubmitted(true); - setCurrentPage(1); - filterModal.closeModal(); - }, [ - filterModal, - filterCustomer, - filterByType, - filterStartDate, - filterEndDate, - ]); + if (normalizedValue.includes('belum')) { + return 'warning'; + } + return 'neutral'; + }; + + // ===== 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 +164,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 +194,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 +242,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 +280,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 +303,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 +343,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,11 +370,11 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } finally { setIsPdfExportLoading(false); } - }, [customerPaymentExport]); + }, [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( @@ -451,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 && ( @@ -467,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', - }} > - - - - + +
); @@ -517,7 +464,6 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { setTabActions, ]); - // Cleanup on unmount useEffect(() => { return () => { clearTabActions(tabId); @@ -735,17 +681,10 @@ const CustomerPaymentTab = ({ tabId }: CustomerPaymentTabProps) => { } return ( - - {getPaymentStatusText(value)} - + ); }, }, @@ -931,95 +870,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 */} +
+ + +
+ ); diff --git a/src/components/pages/report/finance/tab/DebtSupplierTab.tsx b/src/components/pages/report/finance/tab/DebtSupplierTab.tsx index 5e7781bf..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'; @@ -33,7 +31,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 { useReportTabStore } from '@/stores/report/report-tab.store'; import StatusBadge from '@/components/helper/StatusBadge'; import DebtSupplierSkeleton from '@/components/pages/report/finance/skeleton/DebtSupplierSkeleton'; @@ -271,13 +269,13 @@ 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( 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/LogisticStockTabs.tsx b/src/components/pages/report/logistic-stock/LogisticStockTabs.tsx index 1e2d2824..1e3f4109 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 { useReportTabStore } from '@/stores/report/report-tab.store'; const LogisticStockTabs = () => { + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useReportTabStore((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/filter/PurchasesPerSupplierFilter.ts b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts new file mode 100644 index 00000000..b3d9943b --- /dev/null +++ b/src/components/pages/report/logistic-stock/filter/PurchasesPerSupplierFilter.ts @@ -0,0 +1,39 @@ +import * as yup from 'yup'; + +export type PurchasesPerSupplierFilterType = { + 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.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/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..36476956 100644 --- a/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx +++ b/src/components/pages/report/logistic-stock/tab/PurchasesPerSupplierTab.tsx @@ -1,37 +1,54 @@ -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 SelectInput, { - useSelect, - OptionType, -} from '@/components/input/SelectInput'; +import Dropdown from '@/components/Dropdown'; import DateInput from '@/components/input/DateInput'; +import { useSelect } from '@/components/input/SelectInput'; +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 } 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 { generatePurchasesPerSupplierPDF } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportPDF'; import { generatePurchasesPerSupplierExcel } from '@/components/pages/report/logistic-stock/export/PurchasesPerSupplierExportXLSX'; -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 { useReportTabStore } from '@/stores/report/report-tab.store'; +import PurchasePerSupplierSkeleton from '@/components/pages/report/logistic-stock/skeleton/PurchasePerSupplierSkeleton'; -const PurchasesPerSupplierTab = () => { +interface PurchasesPerSupplierTabProps { + tabId: string; +} + +interface FilterParams { + area_id?: string; + supplier_id?: string; + product_id?: string; + product_category_id?: string; + start_date?: string; + end_date?: string; + sort_by?: string; + filter_by?: string; +} + +const PurchasesPerSupplierTab = ({ tabId }: PurchasesPerSupplierTabProps) => { // ===== STATE MANAGEMENT ===== const [isPdfExportLoading, setIsPdfExportLoading] = useState(false); const [isExcelExportLoading, setIsExcelExportLoading] = useState(false); @@ -39,31 +56,17 @@ const PurchasesPerSupplierTab = () => { // ===== PAGINATION STATE ===== const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + 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); - // ===== 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 ===== const { options: areaOptions, isLoadingOptions: isLoadingAreas } = useSelect( AreaApi.basePath, 'id', @@ -100,151 +103,206 @@ 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); + const handleFilterModalOpen = () => { + filterModal.openModal(); + formik.validateForm(); + }; + + // ===== 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, }, - [updateFilter] + validationSchema: PurchasesPerSupplierFilterSchema, + onSubmit: (values, { setSubmitting }) => { + setFilterParams({ + 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); + setCurrentPage(1); + setSubmitting(false); + }, + 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; + formik.setFieldValue('start_date', value || null); + + if (value && formik.values.end_date) { + const startDate = new Date(value); + const endDateObj = new Date(formik.values.end_date); + + 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); + } + }, + [formik, dateErrorShown] ); - 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); + const handleEndDateChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('end_date', value || null); + + if (value && formik.values.start_date) { + const startDateObj = new Date(formik.values.start_date); + 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); + } }, - [updateFilter] + [formik, dateErrorShown] ); - 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] - ); + // ===== 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 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 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 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 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 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 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 startDateChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - updateFilter('start_date', val || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); + 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 endDateChangeHandler = useCallback< - ChangeEventHandler - >( - (e) => { - const val = e.target.value; - updateFilter('end_date', val || ''); - setIsSubmitted(false); - }, - [updateFilter] - ); + 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]); - 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'); - setIsSubmitted(false); - }, [updateFilter]); + // ===== ACTIVE FILTERS COUNT ===== + const activeFiltersCount = useMemo(() => { + let count = 0; - const handleSubmit = useCallback(() => { - setIsSubmitted(true); - setCurrentPage(1); - }, []); + if (filterParams.start_date || filterParams.end_date) { + count += 1; + } + + if (filterParams.area_id) { + count += 1; + } + + if (filterParams.supplier_id) { + count += 1; + } + + if (filterParams.product_id) { + count += 1; + } + + if (filterParams.product_category_id) { + count += 1; + } + + if (filterParams.filter_by) { + count += 1; + } + + if (filterParams.sort_by) { + count += 1; + } + + return count; + }, [filterParams]); + + const hasFilters = activeFiltersCount > 0; // ===== DATA FETCHING ===== const { data: purchasePerSupplier, isLoading } = useSWR( isSubmitted ? () => { const params = { - area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') - : undefined, - supplier_id: - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id.join(',') - : undefined, - product_id: - tableFilterState.product_id.length > 0 - ? tableFilterState.product_id.join(',') - : undefined, - product_category_id: - tableFilterState.product_category_id.length > 0 - ? tableFilterState.product_category_id.join(',') - : undefined, - received_date: - tableFilterState.filter_by === 'received_date' - ? tableFilterState.start_date || undefined - : undefined, - po_date: - tableFilterState.filter_by === 'po_date' - ? tableFilterState.start_date || 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, + 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, + filter_by: filterParams.filter_by, page: currentPage, limit: pageSize, }; @@ -258,8 +316,8 @@ const PurchasesPerSupplierTab = () => { params.supplier_id, params.product_id, params.product_category_id, - params.received_date, - params.po_date, + 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, @@ -288,34 +346,14 @@ const PurchasesPerSupplierTab = () => { LogisticPurchasePerSupplierReport[] | null > => { const params = { - area_id: - tableFilterState.area_id.length > 0 - ? tableFilterState.area_id.join(',') - : undefined, - supplier_id: - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id.join(',') - : undefined, - product_id: - tableFilterState.product_id.length > 0 - ? tableFilterState.product_id.join(',') - : undefined, - product_category_id: - tableFilterState.product_category_id.length > 0 - ? tableFilterState.product_category_id.join(',') - : undefined, - received_date: - tableFilterState.filter_by === 'received_date' - ? tableFilterState.start_date || undefined - : undefined, - po_date: - tableFilterState.filter_by === 'po_date' - ? tableFilterState.start_date || 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, + 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, + filter_by: filterParams.filter_by, limit: 100, page: 1, }; @@ -325,8 +363,8 @@ const PurchasesPerSupplierTab = () => { params.supplier_id, params.product_id, params.product_category_id, - params.received_date, - params.po_date, + 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, @@ -338,7 +376,7 @@ const PurchasesPerSupplierTab = () => { return isResponseSuccess(response) ? (response.data as unknown as LogisticPurchasePerSupplierReport[]) : null; - }, [tableFilterState]); + }, [filterParams]); // ===== EXPORT HANDLERS ===== const handleExportExcel = useCallback(async () => { @@ -378,59 +416,52 @@ const PurchasesPerSupplierTab = () => { 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 supplierName = - tableFilterState.supplier_id.length > 0 - ? tableFilterState.supplier_id - .map( - (id) => - supplierOptions.find((opt) => opt.value === Number(id))?.label - ) - .filter(Boolean) - .join(', ') || 'Semua Supplier' - : 'Semua Supplier'; + const supplierName = filterParams.supplier_id + ? supplierOptions + .filter((opt) => + filterParams.supplier_id?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.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' - : 'Semua Produk'; + const productName = filterParams.product_id + ? productOptions + .filter((opt) => + filterParams.product_id?.split(',').includes(String(opt.value)) + ) + .map((opt) => opt.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' - : 'Semua Kategori Produk'; + const productCategoryName = filterParams.product_category_id + ? productCategoryOptions + .filter((opt) => + filterParams.product_category_id + ?.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: tableFilterState.filter_by || 'received_date', - start_date: tableFilterState.start_date || '', - end_date: tableFilterState.end_date || '', + filter_by: filterParams.filter_by, + start_date: filterParams.start_date, + end_date: filterParams.end_date, }; await generatePurchasesPerSupplierPDF({ @@ -445,33 +476,108 @@ const PurchasesPerSupplierTab = () => { } }, [ logisticPurchasePerSupplierExport, - tableFilterState, + filterParams, areaOptions, supplierOptions, productOptions, productCategoryOptions, ]); - // ===== PAGINATION HANDLERS ===== - const handlePageChange = (page: number) => { - setCurrentPage(page); - }; + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); - const handleRowChange = (pageSize: number) => { - setPageSize(pageSize); - }; + useEffect(() => { + setTabActions( + tabId, +
+ - const handleNextPage = () => { - if (meta && currentPage < meta.total_pages) { - setCurrentPage(currentPage + 1); - } - }; + +
+ - const handlePrevPage = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; + Export + +
+ + +
+ + } + > + + + +
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + filterModal.open, + setTabActions, + ]); + + useEffect(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); const getTableColumns = ( summary: LogisticPurchasePerSupplierSummary @@ -485,11 +591,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 +605,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 +615,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 +625,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 +635,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 +645,7 @@ const PurchasesPerSupplierTab = () => { id: 'qty', header: 'QTY', accessorKey: 'qty', + enableSorting: false, cell: (props) => { const value = props.row.original.qty; return
{formatNumber(value)}
; @@ -549,6 +660,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 +675,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 +690,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 +705,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 +720,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 +735,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 +745,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 +756,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 +820,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 +861,190 @@ const PurchasesPerSupplierTab = () => { ); }) )} - - {meta && data.length > 0 && ( -
- +
+ + {/* Filter Modal */} + + {/* Modal Header */} +
+
+ +

Filter Data

+
+
- )} - +
+
+ {/* Date Filter */} +
+ +
+ +
+ +
+
+ + {/* Area Filter */} + { + formik.setFieldValue( + 'area_ids', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingAreas} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Supplier Filter */} + { + formik.setFieldValue( + 'supplier_ids', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingSuppliers} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Filter */} + { + formik.setFieldValue( + 'product_ids', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingProducts} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Product Category Filter */} + { + formik.setFieldValue( + 'product_category_ids', + Array.isArray(val) && val.length > 0 + ? val.map((v) => String(v.value)).join(',') + : null + ); + }} + isLoading={isLoadingProductCategories} + isClearable + className={{ wrapper: 'w-full' }} + /> + + {/* Filter By Type */} + { + if (!Array.isArray(val)) { + formik.setFieldValue( + 'filter_by', + val?.value?.toString() || null + ); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + /> + + {/* Sort By */} + { + if (!Array.isArray(val)) { + formik.setFieldValue( + 'sort_by', + val?.value?.toString() || null + ); + } + }} + className={{ wrapper: 'w-full' }} + isClearable={true} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ ); }; 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/MarketingReportContent.tsx b/src/components/pages/report/marketing/MarketingReportContent.tsx deleted file mode 100644 index e38a39d4..00000000 --- a/src/components/pages/report/marketing/MarketingReportContent.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { JSX, 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: , - }, -]; - -const MarketingReportContent = () => { - const [activeTab, setActiveTab] = useState('daily'); - - return ( -
- -
- ); -}; - -export default MarketingReportContent; diff --git a/src/components/pages/report/marketing/MarketingTabs.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx new file mode 100644 index 00000000..8a02a0c2 --- /dev/null +++ b/src/components/pages/report/marketing/MarketingTabs.tsx @@ -0,0 +1,45 @@ +'use client'; + +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 { useReportTabStore } from '@/stores/report/report-tab.store'; + +const MarketingReportContent = () => { + const [activeTabId, setActiveTabId] = useState('1'); + const tabActions = useReportTabStore((state) => state.tabActions); + + const tabs = [ + { + id: '1', + label: 'Penjualan Harian', + content: , + }, + { + id: '2', + label: 'HPP Harian Kandang', + content: , + }, + ]; + + return ( +
+ +
+ ); +}; + +export default MarketingReportContent; diff --git a/src/components/pages/report/marketing/export/DailyMarketingReportPDF.tsx b/src/components/pages/report/marketing/export/DailyMarketingExportPDF.tsx similarity index 100% rename from src/components/pages/report/marketing/export/DailyMarketingReportPDF.tsx rename to src/components/pages/report/marketing/export/DailyMarketingExportPDF.tsx 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..d43213f1 --- /dev/null +++ b/src/components/pages/report/marketing/export/DailyMarketingExportXLSX.tsx @@ -0,0 +1,121 @@ +'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: 'HPP Amount (Rp)', key: 'hppAmount', width: 20 }, + { 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), + hppAmount: formatCurrency(item.hpp_amount || 0), + salesAmount: formatCurrency(item.sales_amount || 0), + }); + }); + + // Add TOTAL row if summary data is available + if (params.summaryTotal) { + worksheet.addRow({ + no: 'TOTAL', + soDate: '', + 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: formatCurrency(params.summaryTotal.average_sales_price || 0), + hppPrice: formatCurrency(params.summaryTotal.total_hpp_price_per_kg || 0), + hppAmount: formatCurrency(params.summaryTotal.total_hpp_amount || 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/filter/DailyMarketingFilter.ts b/src/components/pages/report/marketing/filter/DailyMarketingFilter.ts new file mode 100644 index 00000000..85c765a9 --- /dev/null +++ b/src/components/pages/report/marketing/filter/DailyMarketingFilter.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/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/DailyMarketingSkeleton.tsx b/src/components/pages/report/marketing/skeleton/DailyMarketingSkeleton.tsx new file mode 100644 index 00000000..ad68b8f6 --- /dev/null +++ b/src/components/pages/report/marketing/skeleton/DailyMarketingSkeleton.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; 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/DailyMarketingReportContent.tsx b/src/components/pages/report/marketing/tab/DailyMarketingReportContent.tsx deleted file mode 100644 index ca5ec12f..00000000 --- a/src/components/pages/report/marketing/tab/DailyMarketingReportContent.tsx +++ /dev/null @@ -1,472 +0,0 @@ -'use client'; - -import { ChangeEventHandler, useEffect, useState } from 'react'; -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/DailyMarketingReportPDF'; - -import { Area } from '@/types/api/master-data/area'; -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'; -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'; - -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: '', - }, - 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', - }, - }); - - 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 - ); - 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) : '' - ); - }; - - const [selectedWarehouse, setSelectedWarehouse] = useState( - null - ); - 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 { - 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 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()); - - params.set('limit', '9999999'); - - const queryString = `?${params.toString()}`; - - try { - const dailyMarketingsReport = - await httpClient( - `${MarketingReportApi.basePath}${queryString}` - ); - - if (isResponseError(dailyMarketingsReport)) { - toast.error('Gagal melakukan export penjualan harian! Coba lagi.'); - return; - } - - const openPdf = async () => { - const dailyMarketingReportPdfBlob = await pdf( - - ).toBlob(); - - 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.'); - } - - setIsLoadingExportingToPdf(false); - }; - - 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

-
- - {/* Filters */} -
-
- - - - - - - - - - - -
- -
- - - - -
- - - - - - Export{' '} - - - } - > - - - - - -
-
-
- - -
- ); -}; - -export default DailyMarketingReportContent; diff --git a/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx new file mode 100644 index 00000000..a336b671 --- /dev/null +++ b/src/components/pages/report/marketing/tab/DailyMarketingTab.tsx @@ -0,0 +1,939 @@ +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, + formatTitleCase, +} 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 DailyMarketingReportPDF from '@/components/pages/report/marketing/export/DailyMarketingExportPDF'; +import { generateDailyMarketingExcel } from '@/components/pages/report/marketing/export/DailyMarketingExportXLSX'; +import { pdf } from '@react-pdf/renderer'; +import toast from 'react-hot-toast'; +import { Icon } from '@iconify/react'; +import { useFormik } from 'formik'; +import { + 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 { 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'; +import { isResponseError } from '@/lib/api-helper'; +import { + MARKETING_DATE_FILTER_TYPE_OPTIONS, + MARKETING_TYPE_OPTIONS, +} from '@/config/constant'; +import Badge from '@/components/Badge'; + +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, + }, + 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); + }, + }); + + // ===== SEARCH CHANGE HANDLER ===== + const searchChangeHandler = useCallback( + (e: React.ChangeEvent) => { + setSearchValue(e.target.value); + }, + [] + ); + + // ===== 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 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 data: DailyMarketingRow[] = useMemo( + () => + isResponseSuccess(dailyMarketings) + ? (dailyMarketings?.data as DailyMarketingRow[]) || [] + : [], + [dailyMarketings] + ); + + const summaryTotal = useMemo( + () => + isResponseSuccess(dailyMarketings) && dailyMarketings?.total + ? dailyMarketings.total + : undefined, + [dailyMarketings] + ); + + // ===== 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 response = await httpClient( + `${MarketingReportApi.basePath}${queryString}` + ); + + if (isResponseError(response)) { + return null; + } + + return response.data || []; + } catch { + return null; + } + }, [filterParams, searchValue]); + + // ===== EXPORT HANDLERS ===== + const handleExportExcel = useCallback(async () => { + setIsExcelExportLoading(true); + try { + const allDataForExport = await dailyMarketingsExport(); + + if (!allDataForExport || allDataForExport.length === 0) { + toast.error('Tidak ada data untuk diekspor.'); + return; + } + + const period = + filterParams.start_date && filterParams.end_date + ? `${formatDate(filterParams.start_date, 'DD-MMM-YYYY')}_to_${formatDate(filterParams.end_date, 'DD-MMM-YYYY')}` + : undefined; + + await generateDailyMarketingExcel({ + data: allDataForExport, + summaryTotal: summaryTotal, + period: period, + }); + + toast.success('Excel berhasil dibuat dan diunduh.'); + } catch { + toast.error('Gagal membuat Excel. Silakan coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } + }, [filterParams, dailyMarketingsExport, summaryTotal]); + + 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 dailyMarketingReportPdfBlob = await pdf( + + ).toBlob(); + + const dailyMarketingReportPdfUrl = URL.createObjectURL( + dailyMarketingReportPdfBlob + ); + window.open(dailyMarketingReportPdfUrl, '_blank'); + + toast.success('PDF berhasil dibuat.'); + } catch { + toast.error('Gagal membuat PDF. Silakan coba lagi.'); + } finally { + setIsPdfExportLoading(false); + } + }, [dailyMarketingsExport, summaryTotal]); + + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((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', + cell: (props) => ( + + {formatTitleCase(props.row.original.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; + }; + + return ( + <> +
+ {!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: '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', + }} + /> + )} + + + {/* 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 DailyMarketingTab; diff --git a/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx b/src/components/pages/report/marketing/tab/HppPerKandangTab.tsx index c0371abf..e106dbf4 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,17 +16,41 @@ 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'; -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'; 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 { useReportTabStore } from '@/stores/report/report-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 +61,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 +333,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 +357,7 @@ const HppPerKandangTab = () => { ); return isResponseSuccess(response) ? response.data : null; - }, [tableFilterState]); + }, [filterParams]); // ===== TABLE COLUMNS DEFINITION ===== const allFeedSuppliers = useMemo(() => { @@ -373,38 +422,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 +456,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 +476,7 @@ const HppPerKandangTab = () => { } }, [ hppPerKandangExport, - tableFilterState, + filterParams, areaOptions, locationOptions, kandangOptions, @@ -440,6 +484,102 @@ const HppPerKandangTab = () => { allDocSuppliers, ]); + // ===== REGISTER TAB ACTIONS TO STORE ===== + const setTabActions = useReportTabStore((state) => state.setTabActions); + const clearTabActions = useReportTabStore((state) => state.clearTabActions); + + useEffectHook(() => { + setTabActions( + tabId, +
+ + + +
+ + + Export + +
+ + +
+ + } + > + + + +
+ ); + }, [ + tabId, + hasFilters, + activeFiltersCount, + isAnyExportLoading, + filterModal.open, + setTabActions, + ]); + + useEffectHook(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); + const getTableColumns = (): ColumnDef[] => { const tableColumns: ColumnDef[] = [ { @@ -682,161 +822,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 +891,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 */} +
+ + +
+ +
+ ); }; diff --git a/src/components/pages/report/production-result/ProductionResultContent.tsx b/src/components/pages/report/production-result/ProductionResultContent.tsx deleted file mode 100644 index d79d4c94..00000000 --- a/src/components/pages/report/production-result/ProductionResultContent.tsx +++ /dev/null @@ -1,527 +0,0 @@ -'use client'; - -import { useState } from 'react'; -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 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 { BaseKandang } from '@/types/api/master-data/kandang'; -import { AreaApi, LocationApi } from '@/services/api/master-data'; -import { - ProjectFlockApi, - ProjectFlockKandangApi, -} from '@/services/api/production'; -import { - BaseProjectFlockKandang, - ProjectFlockKandang, -} from '@/types/api/production/project-flock-kandang'; -import { isResponseError, isResponseSuccess } from '@/lib/api-helper'; -import Pagination from '@/components/Pagination'; -import { ProductionResultReportApi } from '@/services/api/report/production-result'; -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 { pdf } from '@react-pdf/renderer'; - -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); - - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - - const [isLoadingSearch, setIsLoadingSearch] = useState(false); - - const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] = - useState(false); - - 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); - - const { - setInputValue: setAreaInputValue, - options: areaOptions, - isLoadingOptions: isLoadingAreaOptions, - loadMore: loadMoreAreas, - } = useSelect(AreaApi.basePath, 'id', 'name'); - - const areaChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedArea(val as OptionType); - - setSelectedLocation(null); - - setSelectedProjectFlock(null); - - setSelectedProjectFlockKandang(null); - }; - - const { - setInputValue: setLocationInputValue, - options: locationOptions, - isLoadingOptions: isLoadingLocationOptions, - loadMore: loadMoreLocations, - } = useSelect(LocationApi.basePath, 'id', 'name', 'search', { - area_id: selectedArea ? ((selectedArea as OptionType).value as string) : '', - }); - - const locationChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedLocation(val as OptionType); - - setSelectedProjectFlock(null); - - setSelectedProjectFlockKandang(null); - }; - - const { - setInputValue: setProjectFlockInputValue, - options: projectFlockOptions, - isLoadingOptions: isLoadingProjectFlockOptions, - loadMore: loadMoreProjectFlocks, - } = useSelect( - ProjectFlockApi.basePath, - 'id', - 'flock_name', - 'search', - { - area_id: selectedArea - ? ((selectedArea as OptionType).value as string) - : '', - location_id: selectedLocation - ? ((selectedLocation as OptionType).value as string) - : '', - category: 'LAYING', - } - ); - - const projectFlockChangeHandler = (val: OptionType | OptionType[] | null) => { - setSelectedProjectFlock(val as OptionType); - - setSelectedProjectFlockKandang(null); - }; - - const { - setInputValue: setProjectFlockKandangInputValue, - options: projectFlockKandangOptions, - isLoadingOptions: isLoadingProjectFlockKandangOptions, - loadMore: loadMoreProjectFlockKandangs, - } = useSelect( - ProjectFlockKandangApi.basePath, - 'id', - 'kandang.name', - 'search', - { - area_id: selectedArea - ? ((selectedArea as OptionType).value as string) - : '', - location_id: selectedLocation - ? ((selectedLocation as OptionType).value as string) - : '', - project_flock_id: selectedProjectFlock - ? ((selectedProjectFlock as OptionType).value as string) - : '', - } - ); - - const projectFlockKandangChangeHandler = ( - val: OptionType | OptionType[] | null - ) => { - setSelectedProjectFlockKandang(val as OptionType); - }; - - const exportToExcelHandler = async () => { - setIsLoadingExportingToExcel(true); - - await ProductionResultReportApi.exportProductionResultToExcel( - projectFlockKandangs - ); - - setIsLoadingExportingToExcel(false); - }; - - const exportToPdfHandler = async () => { - setIsLoadingExportingToPdf(true); - - try { - let projectFlockKandangsData: BaseProjectFlockKandang[] = []; - - 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 mappedProductionResults: { - projectFlockKandang: BaseProjectFlockKandang; - productionResult: ProductionResult[] | null; - }[] = await Promise.all( - projectFlockKandangsData.map(async (projectFlockKandang) => { - const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`; - const getProductionResultRes = await httpClient< - BaseApiResponse - >(getProductionResultPath); - - return { - projectFlockKandang, - productionResult: isResponseSuccess(getProductionResultRes) - ? getProductionResultRes.data - : null, - }; - }) - ); - - if (mappedProductionResults.length === 0) { - toast.error('Tidak ada data untuk diexport.'); - setIsLoadingExportingToPdf(false); - return; - } - - const openPdf = async () => { - const productionResultPdfBlob = await pdf( - - ).toBlob(); - - const productionResultReportPdfUrl = URL.createObjectURL( - productionResultPdfBlob - ); - window.open(productionResultReportPdfUrl, '_blank'); - }; - - await openPdf(); - } catch (error) { - console.error(error); - toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); - } - - setIsLoadingExportingToPdf(false); - }; - - const searchHandler = async () => { - setProjectFlockKandangs(null); - setIsLoadingSearch(true); - - try { - if (selectedProjectFlockKandang) { - const projectFlockKandangResponse = - await ProjectFlockKandangApi.getSingle( - selectedProjectFlockKandang?.value as number - ); - - if ( - !projectFlockKandangResponse || - isResponseError(projectFlockKandangResponse) - ) { - throw new Error(); - } - - 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(); - }; - - 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) => ( - - ))} - -
- - setPage((currPage) => - currPage > 1 ? currPage - 1 : currPage - ) - } - onNextPage={() => - setPage((currPage) => - projectFlockKandangMetadata?.total_pages && - currPage < projectFlockKandangMetadata.total_pages - ? currPage + 1 - : currPage - ) - } - onPageChange={(pageNumber) => setPage(pageNumber)} - rowOptions={[10, 20, 50, 100]} - onRowChange={setPageSize} - /> -
-
- )} -
-
- ); -}; - -export default ProductionResultContent; 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..6f5e4410 --- /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/ProductionResultProjectFlockKandangTab'; +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/ProductionResultReportPDF.tsx b/src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx similarity index 97% rename from src/components/pages/report/production-result/ProductionResultReportPDF.tsx rename to src/components/pages/report/production-result/export/ProductionResultExportPDF.tsx index eabb03bf..76336569 100644 --- a/src/components/pages/report/production-result/ProductionResultReportPDF.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/export/ProductionResultExportXLSX.tsx b/src/components/pages/report/production-result/export/ProductionResultExportXLSX.tsx new file mode 100644 index 00000000..af0380c0 --- /dev/null +++ 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); +}; 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..6df3759e --- /dev/null +++ b/src/components/pages/report/production-result/filter/ProductionResultFilter.ts @@ -0,0 +1,59 @@ +import { OptionType } from '@/components/input/SelectInput'; +import * as yup from 'yup'; + +export type ProductionResultFilterProps = { + area_id: string | null; + location_id: string | null; + project_flock_id: string | null; + 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 + .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/skeleton/ProductionResultSkeleton.tsx b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx new file mode 100644 index 00000000..07d33233 --- /dev/null +++ b/src/components/pages/report/production-result/skeleton/ProductionResultSkeleton.tsx @@ -0,0 +1,51 @@ +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'; + +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: ProductionResultColumn[]; + icon: React.ReactNode; + title: string; + subtitle: string; +}) => { + return ( +
+
+
+ +
+ + ); +}; + +export default ProductionResultSkeleton; diff --git a/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx new file mode 100644 index 00000000..9ac5faf6 --- /dev/null +++ b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTab.tsx @@ -0,0 +1,842 @@ +'use client'; + +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; +import { generateProductionResultExcel } from '../export/ProductionResultExportXLSX'; +import toast from 'react-hot-toast'; + +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/tab/ProductionResultProjectFlockKandangTable'; +import { useFormik } from 'formik'; +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'; +import { + ProjectFlockApi, + ProjectFlockKandangApi, +} from '@/services/api/production'; +import { + BaseProjectFlockKandang, + ProjectFlockKandang, +} from '@/types/api/production/project-flock-kandang'; +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'; + +interface ProductionResultTabProps { + tabId: string; +} + +interface FilterParams { + area_id?: string; + location_id?: string; + project_flock_id?: string; + project_flock_kandang_id?: string; +} + +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 filterModal = useModal(); + + // ===== 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), + }, + ], + }, + ]; + + // ===== FORMIK SETUP ===== + const formik = useFormik({ + initialValues: { + area_id: null, + location_id: null, + project_flock_id: null, + kandang_id: null, + }, + validationSchema: ProductionResultFilterSchema, + validateOnBlur: true, + validateOnChange: true, + 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: isLoadingAreas, + loadMore: loadMoreAreas, + } = useSelect(AreaApi.basePath, 'id', 'name', 'search'); + + const { + setInputValue: setLocationInputValue, + options: locationOptions, + isLoadingOptions: isLoadingLocations, + loadMore: loadMoreLocations, + } = useSelect(LocationApi.basePath, 'id', 'name', 'search', { + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) + : '', + }); + + const { + setInputValue: setProjectFlockInputValue, + options: projectFlockOptions, + isLoadingOptions: isLoadingProjectFlocks, + loadMore: loadMoreProjectFlocks, + } = useSelect( + ProjectFlockApi.basePath, + 'id', + 'flock_name', + 'search', + { + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) + : '', + location_id: formik.values.location_id?.value + ? String(formik.values.location_id.value) + : '', + category: 'LAYING', + } + ); + + const { + setInputValue: setProjectFlockKandangInputValue, + options: projectFlockKandangOptions, + isLoadingOptions: isLoadingProjectFlockKandangs, + loadMore: loadMoreProjectFlockKandangs, + } = useSelect( + ProjectFlockKandangApi.basePath, + 'id', + 'kandang.name', + 'search', + { + area_id: formik.values.area_id?.value + ? String(formik.values.area_id.value) + : '', + location_id: formik.values.location_id?.value + ? String(formik.values.location_id.value) + : '', + project_flock_id: formik.values.project_flock_id?.value + ? String(formik.values.project_flock_id.value) + : '', + } + ); + + // ===== FILTER HELPERS ===== + const areaValue = useMemo( + () => formik.values.area_id, + [formik.values.area_id] + ); + + 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 projectFlockKandangsFetch: BaseProjectFlockKandang[] = []; + + if (filterParams.project_flock_kandang_id) { + const projectFlockKandangResponse = + await ProjectFlockKandangApi.getSingle( + Number(filterParams.project_flock_kandang_id) + ); + + projectFlockKandangsFetch = isResponseSuccess( + projectFlockKandangResponse + ) + ? [projectFlockKandangResponse.data] + : []; + } else { + const projectFlockKandangsResponse = + await ProjectFlockKandangApi.getAll({ + area_id: filterParams.area_id, + project_flock_id: filterParams.project_flock_id, + }); + + projectFlockKandangsFetch = isResponseSuccess( + projectFlockKandangsResponse + ) + ? projectFlockKandangsResponse.data + : []; + } + + const productionResultData: ProductionResult[] = []; + + for (const kandang of projectFlockKandangsFetch) { + 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: + projectFlockValue?.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.'); + setIsExcelExportLoading(false); + return; + } + + await generateProductionResultExcel({ + data: productionResultData, + period: '', + }); + } catch { + toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); + } finally { + setIsExcelExportLoading(false); + } + }, [filterParams, projectFlockValue]); + + const exportToPdfHandler = useCallback(async () => { + setIsPdfExportLoading(true); + + try { + let projectFlockKandangsFetch: BaseProjectFlockKandang[] = []; + + if (filterParams.project_flock_kandang_id) { + const projectFlockKandangResponse = + await ProjectFlockKandangApi.getSingle( + Number(filterParams.project_flock_kandang_id) + ); + + projectFlockKandangsFetch = isResponseSuccess( + projectFlockKandangResponse + ) + ? [projectFlockKandangResponse.data] + : []; + } else { + const projectFlockKandangsResponse = + await ProjectFlockKandangApi.getAll({ + area_id: filterParams.area_id, + project_flock_id: filterParams.project_flock_id, + }); + + projectFlockKandangsFetch = isResponseSuccess( + projectFlockKandangsResponse + ) + ? projectFlockKandangsResponse.data + : []; + } + + const mappedProductionResults: { + projectFlockKandang: BaseProjectFlockKandang; + productionResult: ProductionResult[] | null; + }[] = await Promise.all( + projectFlockKandangsFetch.map(async (projectFlockKandang) => { + const getProductionResultPath = `${ProductionResultReportApi.basePath}/${projectFlockKandang.id}?page=1&limit=100`; + const getProductionResultRes = await httpClient< + BaseApiResponse + >(getProductionResultPath); + + return { + projectFlockKandang, + productionResult: isResponseSuccess(getProductionResultRes) + ? getProductionResultRes.data + : null, + }; + }) + ); + + if (mappedProductionResults.length === 0) { + toast.error('Tidak ada data untuk diexport.'); + setIsPdfExportLoading(false); + return; + } + + const openPdf = async () => { + const productionResultPdfBlob = await pdf( + + ).toBlob(); + + const productionResultReportPdfUrl = URL.createObjectURL( + productionResultPdfBlob + ); + window.open(productionResultReportPdfUrl, '_blank'); + }; + + await openPdf(); + } catch (error) { + console.error(error); + toast.error('Gagal melakukan export laporan hasil produksi! Coba lagi.'); + } + + setIsPdfExportLoading(false); + }, [filterParams]); + + // ===== 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, + exportToExcelHandler, + exportToPdfHandler, + setTabActions, + ]); + + useEffect(() => { + return () => { + clearTabActions(tabId); + }; + }, [tabId, clearTabActions]); + + return ( + <> +
+ {!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) => ( + + ) + )} + +
+ + setPage((currPage) => + currPage > 1 ? currPage - 1 : currPage + ) + } + onNextPage={() => + setPage((currPage) => + projectFlockKandangMetadata?.total_pages && + currPage < projectFlockKandangMetadata.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('area_id', val); + formik.setFieldValue('location_id', null); + formik.setFieldValue('project_flock_id', null); + formik.setFieldValue('kandang_id', null); + }} + onInputChange={setAreaInputValue} + onMenuScrollToBottom={loadMoreAreas} + isClearable + isError={formik.touched.area_id && Boolean(formik.errors.area_id)} + errorMessage={formik.errors.area_id} + 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} + isError={ + formik.touched.location_id && Boolean(formik.errors.location_id) + } + errorMessage={formik.errors.location_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} + isError={ + formik.touched.project_flock_id && + Boolean(formik.errors.project_flock_id) + } + errorMessage={formik.errors.project_flock_id} + className={{ wrapper: 'w-full' }} + /> + + { + formik.setFieldValue('kandang_id', val); + }} + onInputChange={setProjectFlockKandangInputValue} + 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' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ + ); +}; + +export default ProductionResultContent; diff --git a/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx b/src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable.tsx similarity index 73% rename from src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx rename to src/components/pages/report/production-result/tab/ProductionResultProjectFlockKandangTable.tsx index e1dfd515..ded97d02 100644 --- a/src/components/pages/report/production-result/ProductionResultProjectFlockKandangTable.tsx +++ b/src/components/pages/report/production-result/tab/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/stores/closing/closing-tab.store.ts b/src/stores/closing/closing-tab.store.ts new file mode 100644 index 00000000..1f81c26a --- /dev/null +++ b/src/stores/closing/closing-tab.store.ts @@ -0,0 +1,21 @@ +'use client'; + +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { + createClosingTabSlice, + ClosingTabSlice, +} from '@/stores/closing/slices/closing-tab.slice'; + +export type ClosingTabStore = ClosingTabSlice; + +export const useClosingTabStore = create()( + devtools( + (...args) => ({ + ...createClosingTabSlice(...args), + }), + { + name: 'ClosingTabStore', + } + ) +); diff --git a/src/stores/closing/slices/closing-tab.slice.ts b/src/stores/closing/slices/closing-tab.slice.ts new file mode 100644 index 00000000..cd47bbdc --- /dev/null +++ b/src/stores/closing/slices/closing-tab.slice.ts @@ -0,0 +1,37 @@ +import { ReactNode } from 'react'; +import { StateCreator } from 'zustand'; + +export type ClosingTabSlice = { + // State - actions per tab ID + tabActions: Record; + + // Actions + setTabActions: (tabId: string, actions: ReactNode) => void; + clearTabActions: (tabId: string) => void; + clearAllTabActions: () => void; +}; + +export const createClosingTabSlice: StateCreator< + ClosingTabSlice, + [], + [], + ClosingTabSlice +> = (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: {} }), +}); diff --git a/src/stores/finance-tab/finance-tab.store.ts b/src/stores/finance-tab/finance-tab.store.ts deleted file mode 100644 index 9b5cf096..00000000 --- a/src/stores/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/production/chickin/chickin.store.ts b/src/stores/production/chickin/chickin.store.ts new file mode 100644 index 00000000..697b1de4 --- /dev/null +++ b/src/stores/production/chickin/chickin.store.ts @@ -0,0 +1,19 @@ +'use client'; + +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { createChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice'; +import { ChickinApprovalSlice } from '@/stores/production/chickin/slices/chickin-approval.slice'; + +export type ChickinStore = ChickinApprovalSlice; + +export const useChickinStore = create()( + devtools( + (...args) => ({ + ...createChickinApprovalSlice(...args), + }), + { + name: 'ChickinStore', + } + ) +); diff --git a/src/stores/production/chickin/slices/chickin-approval.slice.ts b/src/stores/production/chickin/slices/chickin-approval.slice.ts new file mode 100644 index 00000000..30f0a857 --- /dev/null +++ b/src/stores/production/chickin/slices/chickin-approval.slice.ts @@ -0,0 +1,58 @@ +import { StateCreator } from 'zustand'; +import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang'; + +export type ChickinApprovalSlice = { + // State + isChickinApproveModalOpen: boolean; + selectedChickinForApproval: ProjectFlockKandang | null; + isChickinApproveLoading: boolean; + chickinApproveCallback: ((notes?: string) => Promise) | null; + + // Actions + openChickinApproveModal: ( + data: ProjectFlockKandang, + callback: (notes?: string) => Promise + ) => void; + closeChickinApproveModal: () => void; + setChickinApproveLoading: (loading: boolean) => void; + resetChickinApproval: () => void; +}; + +export const createChickinApprovalSlice: StateCreator< + ChickinApprovalSlice, + [], + [], + ChickinApprovalSlice +> = (set) => ({ + // Initial state + isChickinApproveModalOpen: false, + selectedChickinForApproval: null, + isChickinApproveLoading: false, + chickinApproveCallback: null, + + // Actions + openChickinApproveModal: (data, callback) => + set({ + isChickinApproveModalOpen: true, + selectedChickinForApproval: data, + chickinApproveCallback: callback, + }), + + closeChickinApproveModal: () => + set({ + isChickinApproveModalOpen: false, + selectedChickinForApproval: null, + chickinApproveCallback: null, + }), + + setChickinApproveLoading: (loading) => + set({ isChickinApproveLoading: loading }), + + resetChickinApproval: () => + set({ + isChickinApproveModalOpen: false, + selectedChickinForApproval: null, + isChickinApproveLoading: false, + chickinApproveCallback: null, + }), +}); 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/report/report-tab.store.ts b/src/stores/report/report-tab.store.ts new file mode 100644 index 00000000..aad47d17 --- /dev/null +++ b/src/stores/report/report-tab.store.ts @@ -0,0 +1,21 @@ +'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: {} }), +}); 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;