diff --git a/src/components/pages/report/marketing/MarketingTabs.tsx b/src/components/pages/report/marketing/MarketingTabs.tsx index 87139574..2cb488c1 100644 --- a/src/components/pages/report/marketing/MarketingTabs.tsx +++ b/src/components/pages/report/marketing/MarketingTabs.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Tabs from '@/components/Tabs'; import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab'; import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; +import HppPerFarmTab from '@/components/pages/report/marketing/tab/HppPerFarmTab'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; const MarketingReportContent = () => { @@ -21,6 +22,11 @@ const MarketingReportContent = () => { label: 'HPP Harian Kandang', content: , }, + { + id: '3', + label: 'HPP Per Farm', + content: , + }, ]; return ( diff --git a/src/components/pages/report/marketing/tab/HppPerFarmTab.tsx b/src/components/pages/report/marketing/tab/HppPerFarmTab.tsx new file mode 100644 index 00000000..d348c67b --- /dev/null +++ b/src/components/pages/report/marketing/tab/HppPerFarmTab.tsx @@ -0,0 +1,639 @@ +'use client'; + +import { useState, useMemo, useEffect, useCallback } from 'react'; +import useSWR from 'swr'; +import { Icon } from '@iconify/react'; +import { useFormik } from 'formik'; +import toast from 'react-hot-toast'; +import { ColumnDef, Row, flexRender } from '@tanstack/react-table'; +import { AxiosError } from 'axios'; +import { SaleReportApi } from '@/services/api/report/marketing-sale'; +import { LocationApi } from '@/services/api/master-data'; +import { useSelect, OptionType } from '@/components/input/SelectInput'; +import { httpClientFetcher, SWRHttpKey } from '@/services/http/client'; +import { BaseApiResponse } from '@/types/api/api-general'; +import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; +import { useTableFilter } from '@/services/hooks/useTableFilter'; +import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; +import ButtonFilter from '@/components/helper/ButtonFilter'; +import { formatCurrency, formatNumber } from '@/lib/helper'; +import { isResponseSuccess } from '@/lib/api-helper'; +import { + HppPerFarmReport, + HppPerFarmRow, + HppPerFarmFlock, +} from '@/types/api/report/hpp-per-farm'; +import { HppPerKandangReport } from '@/types/api/report/hpp-per-kandang'; +import Modal, { useModal } from '@/components/Modal'; +import Button from '@/components/Button'; +import DateInput from '@/components/input/DateInput'; +import Table from '@/components/Table'; +import HppPerKandangSkeleton from '@/components/pages/report/marketing/skeleton/HppPerKandangSkeleton'; + +interface HppPerFarmTabProps { + tabId: string; +} + +const HppPerFarmTab = ({ tabId }: HppPerFarmTabProps) => { + const [dateError, setDateError] = useState(''); + const [expandedLocations, setExpandedLocations] = useState>( + new Set() + ); + + const filterModal = useModal(); + const setTabActions = useTabActionsStore((state) => state.setTabActions); + const clearTabActions = useTabActionsStore((state) => state.clearTabActions); + + const { + state: tableFilterState, + updateFilter, + setPage, + setPageSize, + toQueryString: getTableFilterQueryString, + reset: resetFilter, + } = useTableFilter<{ + start_date: string; + end_date: string; + locations: OptionType[]; + }>({ + initial: { + start_date: '', + end_date: '', + locations: [], + }, + paramMap: { + page: 'page', + pageSize: 'limit', + start_date: 'start_date', + end_date: 'end_date', + locations: 'location_id', + }, + persist: true, + storeName: 'hpp-per-farm-table', + }); + + const { + options: locationOptions, + setInputValue: setLocationInput, + isLoadingOptions: isLoadingLocations, + loadMore: loadMoreLocations, + } = useSelect(LocationApi.basePath, 'id', 'name', 'search'); + + const formik = useFormik({ + initialValues: { + start_date: tableFilterState.start_date, + end_date: tableFilterState.end_date, + locations: tableFilterState.locations, + }, + onSubmit: (values, { setSubmitting }) => { + updateFilter('start_date', values.start_date, true); + updateFilter('end_date', values.end_date, true); + updateFilter('locations', values.locations, true); + filterModal.closeModal(); + setSubmitting(false); + }, + }); + + const DATE_ERROR_TOAST_ID = 'hpp-farm-date-range-error'; + + const getDateRangeError = (start: string, end: string): string => { + if (!start || !end) return ''; + const startDate = new Date(start); + const endDate = new Date(end); + if (endDate < startDate) + return 'Tanggal akhir tidak boleh lebih kecil dari tanggal mulai'; + const diffDays = + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); + if (diffDays > 31) return 'Rentang tanggal maksimal 31 hari'; + return ''; + }; + + const applyDateValidation = (start: string, end: string) => { + const error = getDateRangeError(start, end); + setDateError(error); + if (error) { + toast.error(error, { duration: Infinity, id: DATE_ERROR_TOAST_ID }); + } else { + toast.dismiss(DATE_ERROR_TOAST_ID); + } + }; + + const formikResetHandler = () => { + resetFilter(); + setDateError(''); + toast.dismiss(DATE_ERROR_TOAST_ID); + formik.resetForm({ + values: { start_date: '', end_date: '', locations: [] }, + }); + filterModal.closeModal(); + }; + + const handleStartDateChange = (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('start_date', value); + applyDateValidation(value, formik.values.end_date); + }; + + const handleEndDateChange = (e: React.ChangeEvent) => { + const value = e.target.value; + formik.setFieldValue('end_date', value); + applyDateValidation(formik.values.start_date, value); + }; + + const isSubmitted = !!tableFilterState.start_date; + + const { data: response, isLoading } = useSWR< + BaseApiResponse, + AxiosError, + SWRHttpKey | null + >( + isSubmitted + ? `${SaleReportApi.basePath}/hpp-per-farm${getTableFilterQueryString()}` + : null, + httpClientFetcher + ); + + const data = isResponseSuccess(response) ? (response.data?.rows ?? []) : []; + const summary = isResponseSuccess(response) + ? response.data?.summary + : undefined; + const meta = + isResponseSuccess(response) && response.meta ? response.meta : null; + + const toggleLocation = useCallback((locationId: number) => { + setExpandedLocations((prev) => { + const next = new Set(prev); + if (next.has(locationId)) { + next.delete(locationId); + } else { + next.add(locationId); + } + return next; + }); + }, []); + + // Reset expansion when page changes + useEffect(() => { + setExpandedLocations(new Set()); + }, [tableFilterState.page]); + + // Inject tab actions + useEffect(() => { + setTabActions( + tabId, +
+ +
+ ); + }, [tabId, setTabActions, tableFilterState, filterModal.openModal]); + + useEffect(() => { + return () => clearTabActions(tabId); + }, [tabId, clearTabActions]); + + // Open filter modal on mount when no date set + useEffect(() => { + if (!tableFilterState.start_date) { + filterModal.openModal(); + } + }, [filterModal.openModal]); + + const columns = useMemo( + (): ColumnDef[] => [ + { + id: 'expand', + header: '', + cell: ({ row }) => { + const hasFlocks = (row.original.flocks?.length ?? 0) > 0; + if (!hasFlocks) return null; + const isExpanded = expandedLocations.has(row.original.location.id); + return ( + + ); + }, + footer: () => null, + }, + { + id: 'no', + header: 'No', + cell: (props) => props.row.index + 1, + footer: () =>
TOTAL
, + }, + { + id: 'farm', + header: 'Farm', + cell: ({ row }) => ( +
{row.original.location.name}
+ ), + footer: () =>
ALL
, + }, + { + id: 'total_cost_rp', + header: 'Total Biaya (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.total_cost_rp)} +
+ ), + footer: () => ( +
+ {formatCurrency(summary?.total_cost_rp ?? 0)} +
+ ), + }, + { + id: 'feed_cost_rp', + header: 'Biaya Pakan (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.feed_cost_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + { + id: 'ovk_cost_rp', + header: 'Biaya OVK (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.ovk_cost_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + { + id: 'bop_cost_rp', + header: 'BOP (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.bop_cost_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + { + id: 'depreciation_rp', + header: 'Penyusutan (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.depreciation_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + { + id: 'other_cost_rp', + header: 'Biaya Lain (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.other_cost_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + { + id: 'egg_weight_recording_kg', + header: 'Bobot Telur Recording (KG)', + cell: ({ row }) => ( +
+ {formatNumber(row.original.egg_weight_recording_kg)} +
+ ), + footer: () => ( +
+ {formatNumber(summary?.total_egg_weight_recording_kg ?? 0)} +
+ ), + }, + { + id: 'egg_weight_do_kg', + header: 'Bobot Telur DO (KG)', + cell: ({ row }) => ( +
+ {formatNumber(row.original.egg_weight_do_kg)} +
+ ), + footer: () => ( +
+ {formatNumber(summary?.total_egg_weight_do_kg ?? 0)} +
+ ), + }, + { + id: 'hpp_per_kg_production', + header: 'HPP/KG Produksi (RP/KG)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.hpp_per_kg_production)} +
+ ), + footer: () => ( +
+ {formatCurrency(summary?.average_hpp_per_kg_production ?? 0)} +
+ ), + }, + { + id: 'hpp_per_kg_sales', + header: 'HPP/KG Penjualan (RP/KG)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.hpp_per_kg_sales)} +
+ ), + footer: () => ( +
+ {formatCurrency(summary?.average_hpp_per_kg_sales ?? 0)} +
+ ), + }, + { + id: 'average_doc_price_rp', + header: 'Rata-rata Harga DOC (RP)', + cell: ({ row }) => ( +
+ {formatCurrency(row.original.average_doc_price_rp)} +
+ ), + footer: () => ( +
-
+ ), + }, + ], + [expandedLocations, toggleLocation, summary] + ); + + const renderCustomRow = useCallback( + (row: Row): React.ReactNode => { + const isExpanded = expandedLocations.has(row.original.location.id); + const flocks = row.original.flocks ?? []; + + const locationRow = ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + + if (!isExpanded || flocks.length === 0) { + return locationRow; + } + + const flockRows = flocks.map((flock: HppPerFarmFlock, i: number) => ( + + + {i + 1} + {flock.flock_name} + {formatCurrency(flock.total_cost_rp)} + {formatCurrency(flock.feed_cost_rp)} + {formatCurrency(flock.ovk_cost_rp)} + {formatCurrency(flock.bop_cost_rp)} + + {formatCurrency(flock.depreciation_rp)} + + {formatCurrency(flock.other_cost_rp)} + + {formatNumber(flock.egg_weight_recording_kg)} + + {formatNumber(flock.egg_weight_do_kg)} + + {formatCurrency(flock.hpp_per_kg_production)} + + + {formatCurrency(flock.hpp_per_kg_sales)} + + + {formatCurrency(flock.average_doc_price_rp)} + + + )); + + return [locationRow, ...flockRows]; + }, + [expandedLocations] + ); + + return ( + <> +
+ {!isSubmitted ? ( + [] + } + icon={ + + } + title='No Filters Selected' + subtitle='Please choose filters to narrow down your results and make your search easier.' + /> + ) : isLoading ? ( + [] + } + icon={ + + } + title='Memuat Data HPP Per Farm' + subtitle='Silakan tunggu sebentar...' + /> + ) : data.length === 0 ? ( + [] + } + icon={ + + } + title='Data Not Yet Available' + subtitle='Please change your filters to get the data.' + /> + ) : ( + 0} + renderCustomRow={renderCustomRow} + 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', + 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

+
+ +
+ +
+
+ {/* Date Range Filter */} +
+ +
+ +
+ +
+ {dateError && ( +
{dateError}
+ )} +
+ + {/* Location Filter */} + + formik.setFieldValue('locations', Array.isArray(val) ? val : []) + } + onInputChange={setLocationInput} + isLoading={isLoadingLocations} + isClearable + onMenuScrollToBottom={loadMoreLocations} + className={{ wrapper: 'w-full' }} + /> +
+ + {/* Modal Footer */} +
+ + +
+ +
+ + ); +}; + +export default HppPerFarmTab; diff --git a/src/services/http/client.ts b/src/services/http/client.ts index 64bf9680..4ab9820c 100644 --- a/src/services/http/client.ts +++ b/src/services/http/client.ts @@ -5,7 +5,7 @@ import { RequestOptions } from '@/services/http/base'; import { redirectToSSO } from '@/lib/auth-helper'; const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; -const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 60_000 }); +const axiosClient = axios.create({ baseURL: BASE_URL, timeout: 300_000 }); axiosClient.interceptors.response.use( (response) => response, @@ -38,7 +38,7 @@ export async function httpClient( method: opts.method ?? 'GET', params: opts.query, data: opts.body, - timeout: opts.timeoutMs ?? 60_000, + timeout: opts.timeoutMs ?? 300_000, withCredentials: isCookieAuth && !isBearerAuth, responseType: opts.responseType, headers: { diff --git a/src/types/api/report/hpp-per-farm.d.ts b/src/types/api/report/hpp-per-farm.d.ts new file mode 100644 index 00000000..df02eccc --- /dev/null +++ b/src/types/api/report/hpp-per-farm.d.ts @@ -0,0 +1,46 @@ +export type HppPerFarmFlock = { + project_flock_id: number; + flock_name: string; + total_cost_rp: number; + feed_cost_rp: number; + ovk_cost_rp: number; + bop_cost_rp: number; + depreciation_rp: number; + other_cost_rp: number; + egg_weight_recording_kg: number; + egg_weight_do_kg: number; + hpp_per_kg_production: number; + hpp_per_kg_sales: number; + average_doc_price_rp: number; +}; + +export type HppPerFarmRow = { + location: { id: number; name: string }; + total_cost_rp: number; + feed_cost_rp: number; + ovk_cost_rp: number; + bop_cost_rp: number; + depreciation_rp: number; + other_cost_rp: number; + egg_weight_recording_kg: number; + egg_weight_do_kg: number; + hpp_per_kg_production: number; + hpp_per_kg_sales: number; + average_doc_price_rp: number; + flocks: HppPerFarmFlock[]; +}; + +export type HppPerFarmSummary = { + total_cost_rp: number; + total_egg_weight_recording_kg: number; + total_egg_weight_do_kg: number; + average_hpp_per_kg_production: number; + average_hpp_per_kg_sales: number; +}; + +export type HppPerFarmReport = { + start_date: string; + end_date: string; + rows: HppPerFarmRow[]; + summary: HppPerFarmSummary; +};