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
15 changed files with 820 additions and 393 deletions
@@ -847,8 +847,7 @@ const DeliveryOrderFormModal = ({}: { initialValues?: Marketing }) => {
}
}}
className='p-3 shadow-button-soft text-base-100 rounded-lg text-sm font-semibold'
disabled={deliveryRejected || isLoading}
isLoading={isLoading}
disabled={deliveryRejected}
>
{marketing?.data?.latest_approval?.step_number === 1 &&
'Approve'}
@@ -1,6 +1,6 @@
'use client';
import { RefObject, useCallback, useMemo, useState } from 'react';
import { RefObject, useCallback, useMemo } from 'react';
import { useFormik } from 'formik';
import { Icon } from '@iconify/react';
import Modal from '@/components/Modal';
@@ -9,8 +9,6 @@ import SelectInput, {
OptionType,
useSelect,
} from '@/components/input/SelectInput';
import DateInput from '@/components/input/DateInput';
import SelectInputRadio from '@/components/input/SelectInputRadio';
import { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import {
MarketingFilterFormValues,
@@ -19,17 +17,12 @@ import {
import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing';
import {
CustomerApi,
ProductApi,
WarehouseApi,
} from '@/services/api/master-data';
import { CustomerApi, ProductApi } from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>;
@@ -41,10 +34,6 @@ interface MarketingFilterModal {
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
};
}
@@ -90,13 +79,6 @@ const MarketingFilterModal = ({
'search'
);
const {
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -105,13 +87,6 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' },
];
const filterByOptions = [
{ value: 'so_date', label: 'Tanggal SO' },
{ value: 'created_at', label: 'Tanggal Dibuat' },
];
const [hasDateError, setHasDateError] = useState(false);
const formik = useFormik<MarketingFilterFormValues>({
initialValues: initialValues || {
product_ids: [],
@@ -119,10 +94,6 @@ const MarketingFilterModal = ({
customer: null,
project_flock: null,
project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
},
validationSchema: MarketingFilterSchema,
@@ -140,12 +111,6 @@ const MarketingFilterModal = ({
Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name:
values.project_flock_kandang?.label || undefined,
warehouse_id: Number(values.warehouse?.value) || undefined,
warehouse_name: values.warehouse?.label || undefined,
start_date: values.start_date || undefined,
end_date: values.end_date || undefined,
filter_by: values.filter_by?.value || undefined,
filter_by_name: values.filter_by?.label || undefined,
};
onSubmit?.(formattedValues);
@@ -168,37 +133,12 @@ const MarketingFilterModal = ({
customer: null,
project_flock: null,
project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
},
});
setHasDateError(false);
onReset?.();
closeModalHandler();
}, [resetForm, onReset, closeModalHandler]);
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('start_date', value);
if (value && formik.values.end_date) {
setHasDateError(new Date(formik.values.end_date) < new Date(value));
} else {
setHasDateError(false);
}
};
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
formik.setFieldValue('end_date', value);
if (value && formik.values.start_date) {
setHasDateError(new Date(value) < new Date(formik.values.start_date));
} else {
setHasDateError(false);
}
};
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]);
};
@@ -267,44 +207,6 @@ const MarketingFilterModal = ({
{/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'>
<div>
<label className='block text-xs font-semibold text-base-content py-2'>
Tanggal
</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
/>
<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={hasDateError}
/>
</div>
</div>
<SelectInputRadio
label='Filter Berdasarkan'
placeholder='Pilih Filter Berdasarkan'
options={filterByOptions}
value={formik.values.filter_by ?? null}
onChange={(val) =>
formik.setFieldValue(
'filter_by',
!Array.isArray(val) ? (val ?? null) : null
)
}
isClearable
/>
{/* select multiple product */}
<SelectInputCheckbox
label='Product'
@@ -370,22 +272,6 @@ const MarketingFilterModal = ({
}
isDisabled={!formik.values.project_flock}
/>
<SelectInput
label='Gudang'
isClearable
placeholder='Pilih Gudang'
options={warehouseOptions}
isLoading={isLoadingWarehouseOptions}
value={formik.values.warehouse}
onChange={(val) =>
formik.setFieldValue(
'warehouse',
!Array.isArray(val) ? (val as OptionType<number> | null) : null
)
}
onInputChange={setWarehouseInputValue}
onMenuScrollToBottom={loadMoreWarehouses}
/>
</div>
{/* Modal Footer */}
@@ -402,7 +288,6 @@ const MarketingFilterModal = ({
<Button
type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
disabled={hasDateError}
>
Apply Filter
</Button>
@@ -203,12 +203,6 @@ const MarketingTable = () => {
project_flock_name: '',
project_flock_kandang_id: '',
project_flock_kandang_name: '',
warehouse_id: '',
warehouse_name: '',
start_date: '',
end_date: '',
filter_by: '',
filter_by_name: '',
sort_by: '',
order_by: '',
},
@@ -220,10 +214,6 @@ const MarketingTable = () => {
customer_id: 'customer_id',
project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_id',
warehouse_id: 'warehouse_id',
start_date: 'start_date',
end_date: 'end_date',
filter_by: 'filter_by',
sort_by: 'sort_by',
order_by: 'sort_order',
},
@@ -233,8 +223,6 @@ const MarketingTable = () => {
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
],
persist: true,
@@ -305,16 +293,6 @@ const MarketingTable = () => {
values.project_flock_kandang_name ?? '',
true
);
updateFilter(
'warehouse_id',
values.warehouse_id ? values.warehouse_id.toString() : '',
true
);
updateFilter('warehouse_name', values.warehouse_name ?? '', true);
updateFilter('start_date', values.start_date ?? '', true);
updateFilter('end_date', values.end_date ?? '', true);
updateFilter('filter_by', values.filter_by ?? '', true);
updateFilter('filter_by_name', values.filter_by_name ?? '', true);
};
const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
@@ -333,12 +311,6 @@ const MarketingTable = () => {
updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', true);
updateFilter('warehouse_id', '', true);
updateFilter('warehouse_name', '', true);
updateFilter('start_date', '', true);
updateFilter('end_date', '', true);
updateFilter('filter_by', '', true);
updateFilter('filter_by_name', '', true);
};
const approveClickHandler = () => {
@@ -461,20 +433,6 @@ const MarketingTable = () => {
label: tableFilterState.project_flock_kandang_name,
}
: null,
warehouse: tableFilterState.warehouse_id
? {
value: Number(tableFilterState.warehouse_id),
label: tableFilterState.warehouse_name,
}
: null,
start_date: tableFilterState.start_date,
end_date: tableFilterState.end_date,
filter_by: tableFilterState.filter_by
? {
value: tableFilterState.filter_by,
label: tableFilterState.filter_by_name,
}
: null,
};
const approveMarketingHandler = async (notes: string) => {
@@ -749,7 +707,7 @@ const MarketingTable = () => {
},
{
accessorKey: 'so_date',
header: 'Tanggal SO',
header: 'Tanggal',
cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy');
},
@@ -795,17 +753,18 @@ const MarketingTable = () => {
cell: (props) => props.row.original.customer.name,
},
{
accessorKey: 'grand_total_so',
header: 'Grand Total SO',
accessorKey: 'grand_total',
accessorFn: (row) =>
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
cell: (props) => {
return formatCurrency(props.row.original?.grand_total_so);
},
},
{
accessorKey: 'grand_total_do',
header: 'Grand Total DO',
cell: (props) => {
return formatCurrency(props.row.original?.grand_total_do);
return formatCurrency(
props.row.original?.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0
);
},
},
{
@@ -952,8 +911,6 @@ const MarketingTable = () => {
'customer_name',
'project_flock_name',
'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
'sort_by',
'order_by',
]}
@@ -1,4 +1,4 @@
import { array, mixed, object, string } from 'yup';
import { array, mixed, object } from 'yup';
import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({
@@ -7,10 +7,6 @@ export const MarketingFilterSchema = object({
customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: mixed<OptionType<number>>().nullable(),
warehouse: mixed<OptionType<number>>().nullable(),
start_date: string().optional(),
end_date: string().optional(),
filter_by: mixed<OptionType<string>>().nullable(),
});
export type MarketingFilterFormValues = {
@@ -19,8 +15,4 @@ export type MarketingFilterFormValues = {
customer: OptionType<number> | null;
project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
};
@@ -2,17 +2,16 @@ import Alert from '@/components/Alert';
import Button from '@/components/Button';
import Card from '@/components/Card';
import RequirePermission from '@/components/helper/RequirePermission';
import DateInput from '@/components/input/DateInput';
import PillBadge from '@/components/PillBadge';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
import { formatDate, formatNumber } from '@/lib/helper';
import { ChickinApi } from '@/services/api/production/chickin';
import { useChickinStore } from '@/stores/production/chickin/chickin.store';
import { BaseApproval } from '@/types/api/api-general';
import { ProjectFlockKandang } from '@/types/api/production/project-flock-kandang';
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,
@@ -24,9 +23,6 @@ const ChickinLogsView = ({
rawDataApprovals: BaseApproval[];
}) => {
const [chickinErrorMessage, setChickinErrorMessage] = useState('');
const [editingChickinId, setEditingChickinId] = useState<number | null>(null);
const [editDate, setEditDate] = useState('');
const [isEditLoading, setIsEditLoading] = useState(false);
const { openChickinApproveModal, openChickinDeleteModal } = useChickinStore();
const handleClickApprove = () => {
@@ -48,23 +44,6 @@ const ChickinLogsView = ({
});
};
const handleSaveChickinDate = async () => {
setIsEditLoading(true);
const res = await ChickinApi.updateChickinDate(
initialValues.id as number,
formatDate(editDate, 'YYYY-MM-DD')
);
setIsEditLoading(false);
if (isResponseSuccess(res)) {
toast.success(res?.message as string);
setEditingChickinId(null);
afterSubmit && afterSubmit();
}
if (isResponseError(res)) {
toast.error(res?.message as string);
}
};
const handleDeleteChickin = (chickinId: number) => {
openChickinDeleteModal(chickinId, async () => {
const deleteRes = await ChickinApi.delete(chickinId);
@@ -154,54 +133,9 @@ const ChickinLogsView = ({
<Icon icon={'mdi:calendar'} width={14} height={14} />{' '}
<span>Tanggal Chick In</span>
</div>
{editingChickinId === chickin.id ? (
<div className='flex flex-col gap-2 items-end w-1/2'>
<DateInput
name='edit_chick_in_date'
value={editDate}
isNestedModal
onChange={(e) => setEditDate(e.target.value)}
/>
<div className='flex flex-row gap-1'>
<Button
color='none'
className='btn-xs btn-ghost text-gray-500'
onClick={() => setEditingChickinId(null)}
disabled={isEditLoading}
>
Batal
</Button>
<Button
color='success'
className='btn-xs text-base-100'
onClick={handleSaveChickinDate}
isLoading={isEditLoading}
disabled={!editDate || isEditLoading}
>
Simpan
</Button>
</div>
</div>
) : (
<div className='flex flex-row gap-2 items-center'>
<span className='text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</span>
<button
className='btn btn-ghost btn-xs p-0 text-gray-400 hover:text-primary'
onClick={() => {
setEditingChickinId(chickin.id);
setEditDate(chickin.chick_in_date);
}}
>
<Icon
icon='mdi:pencil-outline'
width={13}
height={13}
/>
</button>
</div>
)}
<div className='text-end text-gray-500'>
{formatDate(chickin.chick_in_date, 'DD MMM YYYY')}
</div>
</div>
{/* Kandang */}
@@ -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;
+8 -20
View File
@@ -45,11 +45,8 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `${action} marketing ${id}`,
},
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
} catch (error) {
throw error;
}
}
@@ -71,11 +68,8 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `${action} marketing ${ids.join(', ')}`,
},
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
} catch (error) {
throw error;
}
}
@@ -116,11 +110,8 @@ export class SalesOrderService extends BaseApiService<
notes: notes || `Delivery marketing ${id}`,
},
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
} catch (error) {
throw error;
}
}
}
@@ -151,11 +142,8 @@ class MarketingExportService extends BaseApiService<
notes: notes,
},
});
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<Marketing[] | Marketing>>(error)) {
return error.response?.data;
}
return undefined;
} catch (error) {
throw error;
}
}
-24
View File
@@ -6,7 +6,6 @@ import {
import { BaseApiService } from '@/services/api/base';
import { BaseApiResponse } from '@/types/api/api-general';
import { httpClient } from '@/services/http/client';
import axios from 'axios';
export class ChickinService extends BaseApiService<
Chickin,
@@ -17,29 +16,6 @@ export class ChickinService extends BaseApiService<
super(basePath);
}
async updateChickinDate(
projectFlockKandangId: number,
chickInDate: string
): Promise<BaseApiResponse<{ message: string }> | undefined> {
try {
return await httpClient<BaseApiResponse<{ message: string }>>(
`${this.basePath}/chick-in-date`,
{
method: 'PATCH',
body: {
project_flock_kandang_id: projectFlockKandangId,
chick_in_date: chickInDate,
},
}
);
} catch (error: unknown) {
if (axios.isAxiosError<BaseApiResponse<{ message: string }>>(error)) {
return error.response?.data;
}
return undefined;
}
}
/**
* Approve single marketing data
*/
+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: {
-8
View File
@@ -23,8 +23,6 @@ export type BaseMarketing = {
latest_approval: BaseApproval;
sales_order: BaseSalesOrder[];
delivery_order: BaseDeliveryOrder[];
grand_total_do: number;
grand_total_so: number;
};
export type BaseSalesOrder = {
@@ -106,12 +104,6 @@ export type MarketingFilter = {
project_flock_name?: string;
project_flock_kandang_id?: number;
project_flock_kandang_name?: string;
start_date?: string;
end_date?: string;
filter_by?: string;
filter_by_name?: string;
warehouse_id?: number;
warehouse_name?: string;
};
/**
+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;
};