Compare commits

..

13 Commits

Author SHA1 Message Date
Giovanni Gabriel Septriadi 76ee17abb4 Merge branch 'development' into 'production'
feat: add HPP Per Farm report tab with expandable flock rows

See merge request mbugroup/lti-web-client!510
2026-06-06 01:49:42 +00:00
Rivaldi A N S 2f604c9966 Merge branch 'feat/depreciation-report-v2' into 'development'
[FEAT/FE] Depreciation Report V2

See merge request mbugroup/lti-web-client!509
2026-06-05 09:41:53 +00:00
ValdiANS 55737bb96f chore: resolve merge conflict with development in ReportDepreciationTab
Development had added a forceRecompute/Refresh mechanism on top of V1 API.
Kept our V2 implementation which supersedes it — V2 uses a different
endpoint and response shape that doesn't support force_recompute.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:39:48 +07:00
ValdiANS 1ab1c9b027 Merge branch 'development' into feat/depreciation-report-v2 2026-06-05 16:27:49 +07:00
ValdiANS ab6ad7d7b1 feat: migrate depreciation report to V2 API with daily breakdown view
- Add V2 types (ReportDepreciationV2Item, DepreciationV2Meta, DepreciationV2Response) for the new per-day response shape
- Add DepreciationReportV2Api service pointing to /reports/expense/v2/depreciation
- Require projectFlock in filter (was optional); auto-open filter modal on first load when none is selected
- Replace multi-card farm loop with a single project flock card showing farm_name and period only in the header
- Replace kandang sub-table with daily depreciation rows: date, day_n, chickin_date, depreciation_value, pullet_cost_day_n_total, multiplication_percentage, total_value_pullet_after_depreciation
- Add Total Hari (limit) NumberInput field (default 10) to filter modal; remove pagination
- Switch storeName to report-depreciation-v2-table to avoid loading stale localStorage state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:14:57 +07:00
Giovanni Gabriel Septriadi 3641d517ed Merge branch 'rc/00' into 'production'
Rc/00

See merge request mbugroup/lti-web-client!508
2026-06-05 02:44:29 +00:00
Giovanni Gabriel Septriadi 68cadc42fc Merge branch 'rc/00' into 'production'
feat: add date range, filter by, and warehouse filter to marketing table

See merge request mbugroup/lti-web-client!506
2026-06-04 17:33:52 +00:00
Rivaldi A N S 16c5c6c887 Merge branch 'feat/hpp-per-farm' into 'development'
[FEAT/FE] HPP Per Farm

See merge request mbugroup/lti-web-client!505
2026-06-04 08:00:40 +00:00
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
Rivaldi A N S 9b19e306bf Merge branch 'feat/enable-edit-chick-in-date' into 'development'
[FEAT/FE] Enable Edit Chick-In Date

See merge request mbugroup/lti-web-client!502
2026-06-03 07:26:17 +00:00
Rivaldi A N S 5348d47e3c Merge branch 'feat/marketing-filter-range-date' into 'development'
[FEAT/FE] Marketing Filter Range Date

See merge request mbugroup/lti-web-client!501
2026-06-02 06:28:04 +00:00
Rivaldi A N S 80f8b190fd Merge branch 'fix/laying-transition-logic-removal' into 'development'
[FIX/FE] Laying Transition Logic Removal

See merge request mbugroup/lti-web-client!500
2026-06-02 02:47:02 +00:00
8 changed files with 963 additions and 138 deletions
@@ -8,6 +8,7 @@ import { Icon } from '@iconify/react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import Button from '@/components/Button'; import Button from '@/components/Button';
import DateInput from '@/components/input/DateInput'; import DateInput from '@/components/input/DateInput';
import NumberInput from '@/components/input/NumberInput';
import SelectInput, { import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
@@ -24,13 +25,20 @@ export type ReportDepreciationFilterValues = {
location?: OptionType<string>; location?: OptionType<string>;
projectFlock?: OptionType<string>; projectFlock?: OptionType<string>;
period: string | null; period: string | null;
totalDays: number;
}; };
export const ReportDepreciationFilterSchema = yup.object({ export const ReportDepreciationFilterSchema = yup.object({
area: yup.mixed<OptionType<string>>().optional(), area: yup.mixed<OptionType<string>>().optional(),
location: yup.mixed<OptionType<string>>().optional(), location: yup.mixed<OptionType<string>>().optional(),
projectFlock: yup.mixed<OptionType<string>>().optional(), projectFlock: yup
.mixed<OptionType<string>>()
.required('Project Flock wajib dipilih'),
period: yup.string().nullable().required('Periode wajib dipilih'), period: yup.string().nullable().required('Periode wajib dipilih'),
totalDays: yup
.number()
.min(1, 'Minimal 1 hari')
.required('Total Hari wajib diisi'),
}); });
interface ReportDepreciationFilterModalProps { interface ReportDepreciationFilterModalProps {
@@ -47,6 +55,7 @@ const defaultInitialValues: (
location: undefined, location: undefined,
projectFlock: undefined, projectFlock: undefined,
period: initialValues?.period ?? null, period: initialValues?.period ?? null,
totalDays: initialValues?.totalDays ?? 10,
}); });
const ReportDepreciationFilterModal = ({ const ReportDepreciationFilterModal = ({
@@ -196,6 +205,14 @@ const ReportDepreciationFilterModal = ({
isClearable isClearable
isSearchable={true} isSearchable={true}
className={{ wrapper: 'w-full' }} className={{ wrapper: 'w-full' }}
isError={
formik.touched.projectFlock && !!formik.errors.projectFlock
}
errorMessage={
formik.touched.projectFlock
? (formik.errors.projectFlock as string)
: undefined
}
/> />
<DateInput <DateInput
@@ -210,6 +227,31 @@ const ReportDepreciationFilterModal = ({
required required
isNestedModal isNestedModal
/> />
<NumberInput
label='Total Hari'
name='totalDays'
placeholder='Masukkan total hari'
value={formik.values.totalDays}
onChange={(e) => {
const val = Number(e.target.value);
formik.setFieldValue(
'totalDays',
isNaN(val) || val < 1 ? 1 : Math.floor(val)
);
}}
onBlur={formik.handleBlur}
decimalScale={0}
allowNegative={false}
thousandSeparator=''
isError={formik.touched.totalDays && !!formik.errors.totalDays}
errorMessage={
formik.touched.totalDays
? (formik.errors.totalDays as string)
: undefined
}
className={{ wrapper: 'w-full' }}
/>
</div> </div>
<div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'> <div className='p-4 flex justify-between gap-4 border-t border-base-content/10 bg-gray-50'>
@@ -1,12 +1,11 @@
'use client'; 'use client';
import React, { useEffect, useMemo } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Card from '@/components/Card'; import Card from '@/components/Card';
import Pagination from '@/components/Pagination';
import Table from '@/components/Table'; import Table from '@/components/Table';
import ButtonFilter from '@/components/helper/ButtonFilter'; import ButtonFilter from '@/components/helper/ButtonFilter';
import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton'; import ReportExpenseSkeleton from '@/components/pages/report/expense/skeleton/ReportExpenseSkeleton';
@@ -14,11 +13,14 @@ import { useModal } from '@/components/Modal';
import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal'; import ReportDepreciationFilterModal from '@/components/pages/report/expense/tab/ReportDepreciationFilterModal';
import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
import { ReportDepreciation } from '@/types/api/report/report-expense'; import {
import { DepreciationReportApi } from '@/services/api/report/expense-report'; DepreciationV2Response,
ReportDepreciationV2Item,
} from '@/types/api/report/report-expense';
import { DepreciationReportV2Api } from '@/services/api/report/expense-report';
import { useTableFilter } from '@/services/hooks/useTableFilter'; import { useTableFilter } from '@/services/hooks/useTableFilter';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
import { isResponseSuccess } from '@/lib/api-helper'; import { httpClientFetcher } from '@/services/http/client';
import { formatCurrency, formatDate, formatNumber } from '@/lib/helper'; import { formatCurrency, formatDate, formatNumber } from '@/lib/helper';
interface ReportDepreciationTabProps { interface ReportDepreciationTabProps {
@@ -29,8 +31,6 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
const { const {
state: tableFilterState, state: tableFilterState,
updateFilter, updateFilter,
setPage,
setPageSize,
toQueryString: getTableFilterQueryString, toQueryString: getTableFilterQueryString,
reset: resetFilter, reset: resetFilter,
} = useTableFilter<{ } = useTableFilter<{
@@ -38,56 +38,73 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
location?: OptionType<string>; location?: OptionType<string>;
projectFlock?: OptionType<string>; projectFlock?: OptionType<string>;
period: string; period: string;
totalDays: number;
}>({ }>({
initial: { initial: {
area: undefined, area: undefined,
location: undefined, location: undefined,
projectFlock: undefined, projectFlock: undefined,
period: formatDate(Date.now(), 'YYYY-MM-DD'), period: formatDate(Date.now(), 'YYYY-MM-DD'),
totalDays: 10,
}, },
paramMap: { paramMap: {
pageSize: 'limit',
area: 'area_id', area: 'area_id',
location: 'location_id', location: 'location_id',
projectFlock: 'project_flock_id', projectFlock: 'project_flock_id',
period: 'period', period: 'period',
totalDays: 'limit',
}, },
persist: true, persist: true,
storeName: 'report-depreciation-table', storeName: 'report-depreciation-v2-table',
}); });
const { data: depreciationsResponse, isLoading: isLoadingDepreciations } = const swrKey = tableFilterState.projectFlock
useSWR( ? `${DepreciationReportV2Api.basePath}${getTableFilterQueryString()}`
`${DepreciationReportApi.basePath}${getTableFilterQueryString()}`, : null;
DepreciationReportApi.getAllFetcher
);
const depreciations = isResponseSuccess(depreciationsResponse) const { data: depreciationsResponse, isLoading: isLoadingDepreciations } =
useSWR<DepreciationV2Response>(swrKey, httpClientFetcher);
const depreciationMeta =
depreciationsResponse?.status === 'success'
? depreciationsResponse.meta
: null;
const depreciationData =
depreciationsResponse?.status === 'success'
? depreciationsResponse.data ? depreciationsResponse.data
: []; : [];
const filterModal = useModal(); const filterModal = useModal();
const { ref: filterModalRef } = filterModal; const { ref: filterModalRef } = filterModal;
const initialOpenRef = useRef(false);
useEffect(() => {
if (!initialOpenRef.current) {
initialOpenRef.current = true;
if (!tableFilterState.projectFlock) {
filterModal.openModal();
}
}
}, []);
const setTabActions = useTabActionsStore((state) => state.setTabActions); const setTabActions = useTabActionsStore((state) => state.setTabActions);
const clearTabActions = useTabActionsStore((state) => state.clearTabActions); const clearTabActions = useTabActionsStore((state) => state.clearTabActions);
const depreciationKandangColumns: ColumnDef< const depreciationColumns: ColumnDef<ReportDepreciationV2Item>[] = useMemo(
ReportDepreciation['components']['kandang'][0] () => [
>[] = [
{ {
accessorKey: 'kandang_name', accessorKey: 'date',
header: 'Kandang', header: 'Tanggal',
cell: ({ row }) => formatDate(row.original.date, 'DD MMM YYYY'),
}, },
{ {
accessorKey: 'house_type', accessorKey: 'day_n',
header: 'Tipe Kandang', header: 'Hari ke-',
cell: ({ row }) => row.original.house_type.toUpperCase(),
}, },
{ {
accessorKey: 'depreciation_percent', accessorKey: 'chickin_date',
header: 'Persentase Depresiasi', header: 'Tanggal Chick-in',
cell: ({ row }) => row.original.depreciation_percent + '%', cell: ({ row }) => formatDate(row.original.chickin_date, 'DD MMM YYYY'),
}, },
{ {
accessorKey: 'depreciation_value', accessorKey: 'depreciation_value',
@@ -95,21 +112,43 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
cell: ({ row }) => formatCurrency(row.original.depreciation_value), cell: ({ row }) => formatCurrency(row.original.depreciation_value),
}, },
{ {
accessorKey: 'depreciation_source', accessorKey: 'pullet_cost_day_n_total',
header: 'Asal Depresiasi', header: 'Total Harga Pullet Hari ke-N',
cell: ({ row }) => row.original.depreciation_source.toUpperCase(), cell: ({ row }) =>
formatCurrency(
row.original.pullet_cost_day_n_total,
'IDR',
'id-ID',
0,
10
),
}, },
{ {
accessorKey: 'cutover_date', accessorKey: 'multiplication_percentage',
header: 'Tanggal Cutover', header: 'Persentase Multiplikasi',
cell: ({ row }) => formatDate(row.original.cutover_date, 'DD MMM YYYY'), cell: ({ row }) =>
formatNumber(
row.original.multiplication_percentage * 100,
'en-US',
0,
4
) + '%',
}, },
{ {
accessorKey: 'origin_date', accessorKey: 'total_value_pullet_after_depreciation',
header: 'Tanggal Origin', header: 'Total Nilai Pullet Setelah Depresiasi',
cell: ({ row }) => formatDate(row.original.origin_date, 'DD MMM YYYY'), cell: ({ row }) =>
formatCurrency(
row.original.total_value_pullet_after_depreciation,
'IDR',
'id-ID',
0,
10
),
}, },
]; ],
[]
);
const tabActionsElement = useMemo( const tabActionsElement = useMemo(
() => ( () => (
@@ -145,9 +184,27 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
</div> </div>
)} )}
{!isLoadingDepreciations && depreciations.length === 0 && ( {!isLoadingDepreciations && !tableFilterState.projectFlock && (
<ReportExpenseSkeleton <ReportExpenseSkeleton
columns={depreciationKandangColumns} columns={depreciationColumns}
icon={
<Icon
icon='heroicons:chart-bar'
className='text-white'
width={20}
height={20}
/>
}
title='Pilih Project Flock'
subtitle='Silakan pilih Project Flock pada filter untuk melihat data depresiasi.'
/>
)}
{!isLoadingDepreciations &&
tableFilterState.projectFlock &&
depreciationData.length === 0 && (
<ReportExpenseSkeleton
columns={depreciationColumns}
icon={ icon={
<Icon <Icon
icon='heroicons:chart-bar' icon='heroicons:chart-bar'
@@ -161,18 +218,16 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
/> />
)} )}
{!isLoadingDepreciations && depreciations.length > 0 && ( {!isLoadingDepreciations &&
<> depreciationData.length > 0 &&
{depreciations.map((depreciationItem, idx) => ( depreciationMeta && (
<Card <Card
key={idx} title={depreciationMeta.farm_name}
title={depreciationItem.farm_name} subtitle={`Periode: ${formatDate(depreciationMeta.period, 'DD MMM YYYY')}`}
subtitle={`Period: ${formatDate(depreciationItem.period, 'DD MMM YYYY')} | Depresiasi Efektif: ${formatNumber(depreciationItem.depreciation_percent_effective, 'en-US', 0, 10)}% | Nilai Depresiasi: ${formatCurrency(depreciationItem.depreciation_value)} | Total Pullet Cost: ${formatCurrency(depreciationItem.pullet_cost_day_n_total, 'IDR', 'id-ID', 0, 10)}`}
className={{ className={{
wrapper: 'w-full rounded-lg border-none', wrapper: 'w-full rounded-lg border-none',
body: 'p-0', body: 'p-0',
title: title: 'px-2 py-1.5 font-normal text-sm bg-primary text-white',
'px-2 py-1.5 font-normal text-sm bg-primary text-white',
subtitle: subtitle:
'px-2 pb-1.5 bg-primary text-white text-xs font-normal', 'px-2 pb-1.5 bg-primary text-white text-xs font-normal',
collapsible: 'rounded-lg', collapsible: 'rounded-lg',
@@ -181,21 +236,11 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
collapsible={true} collapsible={true}
> >
<Table <Table
data={depreciationItem.components.kandang} data={depreciationData}
columns={depreciationKandangColumns} columns={depreciationColumns}
pageSize={tableFilterState.pageSize} pageSize={depreciationData.length}
page={ page={1}
isResponseSuccess(depreciationsResponse) totalItems={depreciationData.length}
? depreciationsResponse?.meta?.page
: 0
}
totalItems={
isResponseSuccess(depreciationsResponse)
? depreciationsResponse?.meta?.total_results
: 0
}
onPageChange={setPage}
onPageSizeChange={setPageSize}
isLoading={isLoadingDepreciations} isLoading={isLoadingDepreciations}
className={{ className={{
containerClassName: 'w-full mb-0!', containerClassName: 'w-full mb-0!',
@@ -218,27 +263,6 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
}} }}
/> />
</Card> </Card>
))}
<Pagination
totalItems={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.total_results ?? 0)
: 0
}
itemsPerPage={tableFilterState.pageSize}
currentPage={
isResponseSuccess(depreciationsResponse)
? (depreciationsResponse?.meta?.page ?? 0)
: 0
}
onPrevPage={() => setPage(tableFilterState.page - 1)}
onNextPage={() => setPage(tableFilterState.page + 1)}
onPageChange={setPage}
rowOptions={[10, 20, 50, 100]}
onRowChange={setPageSize}
/>
</>
)} )}
</div> </div>
@@ -255,6 +279,7 @@ const ReportDepreciationTab = ({ tabId }: ReportDepreciationTabProps) => {
values.period ? formatDate(values.period, 'YYYY-MM-DD') : '', values.period ? formatDate(values.period, 'YYYY-MM-DD') : '',
true true
); );
updateFilter('totalDays', values.totalDays ?? 10, true);
}} }}
/> />
</> </>
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Tabs from '@/components/Tabs'; import Tabs from '@/components/Tabs';
import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab'; import DailyMarketingReportContent from '@/components/pages/report/marketing/tab/DailyMarketingTab';
import HppPerKandangTab from '@/components/pages/report/marketing/tab/HppPerKandangTab'; 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'; import { useTabActionsStore } from '@/stores/tab-actions/tab-actions.store';
const MarketingReportContent = () => { const MarketingReportContent = () => {
@@ -21,6 +22,11 @@ const MarketingReportContent = () => {
label: 'HPP Harian Kandang', label: 'HPP Harian Kandang',
content: <HppPerKandangTab tabId={'2'} />, content: <HppPerKandangTab tabId={'2'} />,
}, },
{
id: '3',
label: 'HPP Per Farm',
content: <HppPerFarmTab tabId={'3'} />,
},
]; ];
return ( 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;
@@ -4,6 +4,7 @@ import { httpClient, httpClientFetcher } from '@/services/http/client';
import { BaseApiResponse } from '@/types/api/api-general'; import { BaseApiResponse } from '@/types/api/api-general';
import { import {
ReportDepreciation, ReportDepreciation,
ReportDepreciationV2Item,
ReportExpense, ReportExpense,
} from '@/types/api/report/report-expense'; } from '@/types/api/report/report-expense';
@@ -57,3 +58,9 @@ export const DepreciationReportApi = new BaseApiService<
unknown, unknown,
unknown unknown
>('/reports/expense/depreciation'); >('/reports/expense/depreciation');
export const DepreciationReportV2Api = new BaseApiService<
ReportDepreciationV2Item,
unknown,
unknown
>('/reports/expense/v2/depreciation');
+2 -2
View File
@@ -5,7 +5,7 @@ import { RequestOptions } from '@/services/http/base';
import { redirectToSSO } from '@/lib/auth-helper'; import { redirectToSSO } from '@/lib/auth-helper';
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; 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( axiosClient.interceptors.response.use(
(response) => response, (response) => response,
@@ -38,7 +38,7 @@ export async function httpClient<T, B = unknown>(
method: opts.method ?? 'GET', method: opts.method ?? 'GET',
params: opts.query, params: opts.query,
data: opts.body, data: opts.body,
timeout: opts.timeoutMs ?? 60_000, timeout: opts.timeoutMs ?? 300_000,
withCredentials: isCookieAuth && !isBearerAuth, withCredentials: isCookieAuth && !isBearerAuth,
responseType: opts.responseType, responseType: opts.responseType,
headers: { 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;
};
+60
View File
@@ -90,3 +90,63 @@ export type ReportDepreciationSearchParams = {
farm: string | null; farm: string | null;
period: string | null; period: string | null;
}; };
export type ReportDepreciationV2KandangItem = {
kandang_id: number;
kandang_name: string;
transfer_id: number;
depreciation_percent: number;
pullet_cost_day_n: number;
depreciation_value: number;
chickin_date: string;
project_flock_kandang_id: number;
depreciation_source: string;
transfer_date: string;
source_project_flock_id: number;
house_type: string;
multiplication_percentage: number;
cutover_date: string;
origin_date: string;
standard_effective_date: string;
population: number;
transfer_qty: number;
total_value_pullet_after_depreciation: number;
manual_input_id: number;
start_schedule_day: number;
day_n: number;
};
export type ReportDepreciationV2Item = {
date: string;
depreciation_percent_effective: number;
depreciation_value: number;
pullet_cost_day_n_total: number;
multiplication_percentage: number;
day_n: number;
chickin_date: string;
total_value_pullet_after_depreciation: number;
standard_effective_date: string;
total_population: number;
components: {
kandang_count: number;
total_population: number;
kandang: ReportDepreciationV2KandangItem[];
};
};
export type DepreciationV2Meta = {
project_flock_id: number;
farm_name: string;
location_id: number;
period: string;
limit: number;
total_days: number;
};
export type DepreciationV2Response = {
code: number;
status: string;
message: string;
meta: DepreciationV2Meta;
data: ReportDepreciationV2Item[];
};