Compare commits

..

2 Commits

Author SHA1 Message Date
ValdiANS 97ff90996a feat: add Refresh button with force_recompute to ReportDepreciationTab
Adds a Refresh button to the tab actions bar (left of ButtonFilter) that
re-fetches depreciation data with force_recompute=true in the query param,
triggering a server-side recomputation. The arrow-path icon spins while
the request is in flight. Button is styled to match ButtonFilter. The
force_recompute flag resets to false when filters are changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:58:51 +07:00
ValdiANS 7fb86e9759 feat: add HPP Per Farm report tab with expandable flock rows
- Add HppPerFarmReport types (HppPerFarmRow, HppPerFarmFlock, HppPerFarmSummary)
- Add HppPerFarmTab component with useTableFilter persist, date range filter
  (max 30 days, end >= start), location multi-select, and expandable rows
  showing per-flock cost breakdown
- Register new tab in MarketingTabs
- Increase http client default timeout to 300s for long-running report queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:32:50 +07:00
7 changed files with 792 additions and 88 deletions
@@ -463,16 +463,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [selectedKandang]);
// ===== TRANSITION RESTRICTION LOGIC =====
// const isTransitionPeriod = useMemo(() => {
// return (
// initialValues?.is_transition ??
// projectFlockKandangLookup?.is_transition ??
// false
// );
// }, [initialValues, projectFlockKandangLookup]);
// set to false by request: 30 May 2026, 09:11
const isTransitionPeriod = false;
const isTransitionPeriod = useMemo(() => {
return (
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false
);
}, [initialValues, projectFlockKandangLookup]);
const recordingRestriction = useMemo(() => {
let isLaying: boolean;
@@ -486,13 +483,10 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
false;
}
// const isTransition =
// initialValues?.is_transition ??
// projectFlockKandangLookup?.is_transition ??
// false;
// set to false by request: 30 May 2026, 09:11
const isTransition = false;
const isTransition =
initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ??
false;
const currentIsLaying =
type === 'edit'
@@ -11,72 +11,63 @@ export const getRecordingRestriction = (
isTransition: boolean,
currentIsLaying?: boolean
): RecordingRestriction => {
// if (isTransition && !isLaying) {
// const isLayingKandangInTransition = currentIsLaying === true;
if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true;
// if (isLayingKandangInTransition) {
// return {
// canEditStock: false,
// canEditDepletion: true,
// canEditEgg: true,
// isLocked: false,
// lockReason: undefined,
// };
// } else {
// return {
// canEditStock: true,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: false,
// lockReason: undefined,
// };
// }
// }
if (isLayingKandangInTransition) {
return {
canEditStock: false,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
} else {
return {
canEditStock: true,
canEditDepletion: false,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
}
// if (!isLaying && !isTransition && currentIsLaying) {
// return {
// canEditStock: false,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: true,
// lockReason:
// 'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
// };
// }
if (!isLaying && !isTransition && currentIsLaying) {
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
};
}
// if (!isLaying && !isTransition) {
// return {
// canEditStock: true,
// canEditDepletion: true,
// canEditEgg: false,
// isLocked: false,
// lockReason: undefined,
// };
// }
// if (isLaying && !isTransition) {
// return {
// canEditStock: true,
// canEditDepletion: true,
// canEditEgg: true,
// isLocked: false,
// lockReason: undefined,
// };
// }
if (!isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: false,
isLocked: false,
lockReason: undefined,
};
}
if (isLaying && !isTransition) {
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
};
}
// return {
// canEditStock: false,
// canEditDepletion: false,
// canEditEgg: false,
// isLocked: true,
// lockReason: 'Kondisi transisi tidak valid',
// };
// remove recording transition restriction by request: 30 May 2026, 09:11
return {
canEditStock: true,
canEditDepletion: true,
canEditEgg: true,
isLocked: false,
lockReason: undefined,
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
};
@@ -1,10 +1,11 @@
'use client';
import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { ColumnDef } from '@tanstack/react-table';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import Card from '@/components/Card';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table';
@@ -56,11 +57,21 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
storeName: 'report-depreciation-table',
});
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
useSWR(
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`,
DepreciationReportApi.getAllFetcher
);
const [forceRecompute, setForceRecompute] = useState(false);
const {
data: depreciationsResponse,
isLoading: isLoadingDepreciations,
isValidating,
mutate,
} = useSWR(
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}&force_recompute=${forceRecompute}`,
DepreciationReportApi.getAllFetcher
);
const handleRefresh = useCallback(() => {
setForceRecompute(true);
}, [mutate]);
const depreciations = isResponseSuccess(depreciationsResponse)
? depreciationsResponse.data
@@ -114,6 +125,21 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
const tabActionsElement = useMemo(
() => (
<div className='flex flex-row gap-3'>
<Button
variant='outline'
color='none'
onClick={handleRefresh}
disabled={isValidating}
className='rounded-lg max-h-10 font-semibold text-sm gap-1.5 text-base-content/50 border border-base-content/10 shadow-button-soft px-3 py-2.5'
>
<Icon
icon='heroicons:arrow-path'
width={20}
height={20}
className={isValidating ? 'animate-spin' : ''}
/>
Refresh
</Button>
<ButtonFilter
values={tableFilterState}
excludeFields={['page', 'pageSize']}
@@ -123,7 +149,7 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
/>
</div>
),
[tableFilterState]
[tableFilterState, handleRefresh, isValidating]
);
useEffect(() => {
@@ -255,6 +281,8 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
true
);
setForceRecompute(false);
}}
/>
</>
@@ -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: <HppPerKandangTab tabId={'2'} />,
},
{
id: '3',
label: 'HPP Per Farm',
content: <HppPerFarmTab tabId={'3'} />,
},
];
return (
@@ -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<Set<number>>(
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<number>[];
}>({
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<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
applyDateValidation(value, formik.values.end_date);
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HppPerFarmReport>,
AxiosError<BaseApiResponse>,
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,
<div className='flex flex-row gap-3'>
<ButtonFilter
values={{
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
locations: tableFilterState.locations,
}}
fieldGroups={[['start_date', 'end_date']]}
onClick={filterModal.openModal}
variant='outline'
className='px-3 py-2.5'
/>
</div>
);
}, [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<HppPerFarmRow>[] => [
{
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 (
<button
onClick={() => toggleLocation(row.original.location.id)}
className='flex items-center justify-center w-5 h-5 rounded text-base-content/50 hover:text-base-content hover:bg-base-content/10 transition-colors'
>
<Icon
icon={
isExpanded
? 'heroicons:chevron-down'
: 'heroicons:chevron-right'
}
width={14}
height={14}
/>
</button>
);
},
footer: () => null,
},
{
id: 'no',
header: 'No',
cell: (props) => props.row.index + 1,
footer: () => <div className='font-semibold text-gray-900'>TOTAL</div>,
},
{
id: 'farm',
header: 'Farm',
cell: ({ row }) => (
<div className='font-semibold'>{row.original.location.name}</div>
),
footer: () => <div className='font-semibold text-gray-900'>ALL</div>,
},
{
id: 'total_cost_rp',
header: 'Total Biaya (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.total_cost_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary?.total_cost_rp ?? 0)}
</div>
),
},
{
id: 'feed_cost_rp',
header: 'Biaya Pakan (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.feed_cost_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'ovk_cost_rp',
header: 'Biaya OVK (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.ovk_cost_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'bop_cost_rp',
header: 'BOP (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.bop_cost_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'depreciation_rp',
header: 'Penyusutan (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.depreciation_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'other_cost_rp',
header: 'Biaya Lain (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.other_cost_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
{
id: 'egg_weight_recording_kg',
header: 'Bobot Telur Recording (KG)',
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.egg_weight_recording_kg)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary?.total_egg_weight_recording_kg ?? 0)}
</div>
),
},
{
id: 'egg_weight_do_kg',
header: 'Bobot Telur DO (KG)',
cell: ({ row }) => (
<div className='text-right'>
{formatNumber(row.original.egg_weight_do_kg)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatNumber(summary?.total_egg_weight_do_kg ?? 0)}
</div>
),
},
{
id: 'hpp_per_kg_production',
header: 'HPP/KG Produksi (RP/KG)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.hpp_per_kg_production)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary?.average_hpp_per_kg_production ?? 0)}
</div>
),
},
{
id: 'hpp_per_kg_sales',
header: 'HPP/KG Penjualan (RP/KG)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.hpp_per_kg_sales)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>
{formatCurrency(summary?.average_hpp_per_kg_sales ?? 0)}
</div>
),
},
{
id: 'average_doc_price_rp',
header: 'Rata-rata Harga DOC (RP)',
cell: ({ row }) => (
<div className='text-right'>
{formatCurrency(row.original.average_doc_price_rp)}
</div>
),
footer: () => (
<div className='text-right font-semibold text-gray-900'>-</div>
),
},
],
[expandedLocations, toggleLocation, summary]
);
const renderCustomRow = useCallback(
(row: Row<HppPerFarmRow>): React.ReactNode => {
const isExpanded = expandedLocations.has(row.original.location.id);
const flocks = row.original.flocks ?? [];
const locationRow = (
<tr
key={row.id}
className='hover:bg-gray-50 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200'
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className='px-4 py-3 text-xs text-gray-900 whitespace-nowrap border-gray-200'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
if (!isExpanded || flocks.length === 0) {
return locationRow;
}
const flockRows = flocks.map((flock: HppPerFarmFlock, i: number) => (
<tr
key={`flock-${flock.project_flock_id}`}
className='bg-gray-50/70 hover:bg-gray-100 transition-colors border-b border-l border-r border-b-gray-200 border-l-gray-200 border-r-gray-200 [&_td]:px-4 [&_td]:py-2.5 [&_td]:text-xs [&_td]:text-gray-600 [&_td]:whitespace-nowrap'
>
<td />
<td className='text-gray-500'>{i + 1}</td>
<td className='pl-6 text-gray-700 italic'>{flock.flock_name}</td>
<td className='text-right'>{formatCurrency(flock.total_cost_rp)}</td>
<td className='text-right'>{formatCurrency(flock.feed_cost_rp)}</td>
<td className='text-right'>{formatCurrency(flock.ovk_cost_rp)}</td>
<td className='text-right'>{formatCurrency(flock.bop_cost_rp)}</td>
<td className='text-right'>
{formatCurrency(flock.depreciation_rp)}
</td>
<td className='text-right'>{formatCurrency(flock.other_cost_rp)}</td>
<td className='text-right'>
{formatNumber(flock.egg_weight_recording_kg)}
</td>
<td className='text-right'>{formatNumber(flock.egg_weight_do_kg)}</td>
<td className='text-right'>
{formatCurrency(flock.hpp_per_kg_production)}
</td>
<td className='text-right'>
{formatCurrency(flock.hpp_per_kg_sales)}
</td>
<td className='text-right'>
{formatCurrency(flock.average_doc_price_rp)}
</td>
</tr>
));
return [locationRow, ...flockRows];
},
[expandedLocations]
);
return (
<>
<div className='w-full p-0 sm:p-3 flex flex-col gap-3'>
{!isSubmitted ? (
<HppPerKandangSkeleton
columns={
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
}
icon={
<Icon
icon='heroicons:funnel'
className='text-white'
width={20}
height={20}
/>
}
title='No Filters Selected'
subtitle='Please choose filters to narrow down your results and make your search easier.'
/>
) : isLoading ? (
<HppPerKandangSkeleton
columns={
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
}
icon={
<Icon
icon='heroicons:document-report'
className='text-white'
width={20}
height={20}
/>
}
title='Memuat Data HPP Per Farm'
subtitle='Silakan tunggu sebentar...'
/>
) : data.length === 0 ? (
<HppPerKandangSkeleton
columns={
columns as unknown as ColumnDef<HppPerKandangReport['rows'][0]>[]
}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Data Not Yet Available'
subtitle='Please change your filters to get the data.'
/>
) : (
<Table
data={data}
columns={columns}
pageSize={tableFilterState.pageSize}
page={tableFilterState.page}
totalItems={meta?.total_results ?? 0}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoading}
renderFooter={data.length > 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',
}}
/>
)}
</div>
{/* Filter Modal */}
<Modal
ref={filterModal.ref}
className={{
modal: 'p-0',
modalBox: 'p-0 rounded-[0.875rem] xl:max-w-4/12 max-w-sm',
}}
>
{/* Modal Header */}
<div className='flex items-center justify-between gap-2 border-b border-base-content/10 p-4'>
<div className='flex items-center gap-2 text-primary'>
<Icon icon='heroicons:funnel' width={20} height={20} />
<h3 className='font-medium text-sm'>Filter Data</h3>
</div>
<Button
variant='link'
onClick={filterModal.closeModal}
className='text-base-content/50 hover:text-base-content transition-colors cursor-pointer'
>
<Icon icon='heroicons:x-mark' width={20} height={20} />
</Button>
</div>
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
<div className='p-4 flex flex-col gap-3'>
{/* Date Range Filter */}
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Periode
</label>
<div className='flex flex-row gap-1.5 items-center justify-between'>
<DateInput
name='start_date'
value={formik.values.start_date || ''}
onChange={handleStartDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={!!dateError}
/>
<hr className='w-full max-w-3 h-px border-base-content/10' />
<DateInput
name='end_date'
value={formik.values.end_date || ''}
onChange={handleEndDateChange}
className={{ wrapper: 'w-full' }}
isNestedModal
isError={!!dateError}
/>
</div>
{dateError && (
<div className='text-error text-xs mt-1'>{dateError}</div>
)}
</div>
{/* Location Filter */}
<SelectInputCheckbox
label='Lokasi'
placeholder='Pilih Lokasi'
options={locationOptions}
value={formik.values.locations}
onChange={(val) =>
formik.setFieldValue('locations', Array.isArray(val) ? val : [])
}
onInputChange={setLocationInput}
isLoading={isLoadingLocations}
isClearable
onMenuScrollToBottom={loadMoreLocations}
className={{ wrapper: 'w-full' }}
/>
</div>
{/* Modal Footer */}
<div className='flex justify-between items-center gap-4 p-4 border-t border-base-content/10 bg-gray-50'>
<Button
type='reset'
variant='soft'
className='rounded-lg text-base-content/65 bg-transparent border-none hover:bg-base-content/10 hover:text-base-content/65 transition-colors px-3 py-2'
>
Reset Filter
</Button>
<Button
type='submit'
className='min-w-40 text-sm rounded-lg py-3 text-white font-semibold'
disabled={!!dateError || formik.isSubmitting}
>
Apply Filter
</Button>
</div>
</form>
</Modal>
</>
);
};
export default HppPerFarmTab;
+2 -2
View File
@@ -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<T, B = unknown>(
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: {
+46
View File
@@ -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;
};