Compare commits

...

20 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 fce4d52214 Merge branch 'fix/laying-transition-logic-removal' into 'rc/00'
Fix/laying transition logic removal

See merge request mbugroup/lti-web-client!507
2026-06-05 02:41:10 +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
Giovanni Gabriel Septriadi e2354b5ea7 Merge branch 'feat/enable-edit-chick-in-date' into 'rc/00'
feat: add inline edit for chick-in date in chickin logs

See merge request mbugroup/lti-web-client!503
2026-06-04 16:53:13 +00:00
Giovanni Gabriel Septriadi 8f88677191 Merge branch 'feat/marketing-filter-range-date' into 'rc/00'
feat: add date range, filter by, and warehouse filter to marketing table

See merge request mbugroup/lti-web-client!504
2026-06-04 16:52:49 +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
ValdiANS e73af7e252 feat: add date range, filter by, and warehouse filter to marketing table
- Add start_date and end_date range inputs to the marketing filter modal
  with validation that prevents end date from being earlier than start date
- Add 'Filter Berdasarkan' single-select radio (so_date / created_at)
  to let users choose which date field the range applies to
- Add single-select Gudang (warehouse) filter backed by WarehouseApi,
  serialized as warehouse_id query param
- Wire all three new filters into useTableFilter (paramMap, persist,
  excludeKeysFromUrl for label-only fields) and propagate through
  filterSubmitHandler, filterResetHandler, and marketingFilterInitialValues
  so filter state survives page refreshes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 13:22:01 +07: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
ValdiANS 7b4bd7605b fix: remove transition restriction for recording 2026-06-02 09:45:19 +07:00
Rivaldi A N S 9bd646294b Merge branch 'fix/laying-transition-logic-removal' into 'development'
[FIX/FE] Laying Transition Restrict Logic Removal

See merge request mbugroup/lti-web-client!499
2026-05-30 02:19:24 +00:00
ValdiANS 366260608f fix: remove transition restrict logic 2026-05-30 09:13:56 +07:00
14 changed files with 1231 additions and 217 deletions
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { RefObject, useCallback, useMemo } from 'react'; import { RefObject, useCallback, useMemo, useState } from 'react';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
@@ -9,6 +9,8 @@ import SelectInput, {
OptionType, OptionType,
useSelect, useSelect,
} from '@/components/input/SelectInput'; } 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 { MARKETING_APPROVAL_LINE } from '@/config/approval-line';
import { import {
MarketingFilterFormValues, MarketingFilterFormValues,
@@ -17,12 +19,17 @@ import {
import { MarketingFilter } from '@/types/api/marketing/marketing'; import { MarketingFilter } from '@/types/api/marketing/marketing';
import SelectInputCheckbox from '@/components/input/SelectInputCheckbox'; import SelectInputCheckbox from '@/components/input/SelectInputCheckbox';
import { MarketingApi } from '@/services/api/marketing/marketing'; import { MarketingApi } from '@/services/api/marketing/marketing';
import { CustomerApi, ProductApi } from '@/services/api/master-data'; import {
CustomerApi,
ProductApi,
WarehouseApi,
} from '@/services/api/master-data';
import { isResponseSuccess } from '@/lib/api-helper'; import { isResponseSuccess } from '@/lib/api-helper';
import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing'; import { BaseMarketing, BaseSalesOrder } from '@/types/api/marketing/marketing';
import { ProjectFlockApi } from '@/services/api/production'; import { ProjectFlockApi } from '@/services/api/production';
import { ProjectFlock } from '@/types/api/production/project-flock'; import { ProjectFlock } from '@/types/api/production/project-flock';
import { Product } from '@/types/api/master-data/product'; import { Product } from '@/types/api/master-data/product';
import { Warehouse } from '@/types/api/master-data/warehouse';
interface MarketingFilterModal { interface MarketingFilterModal {
ref: RefObject<HTMLDialogElement | null>; ref: RefObject<HTMLDialogElement | null>;
@@ -34,6 +41,10 @@ interface MarketingFilterModal {
customer: OptionType<number> | null; customer: OptionType<number> | null;
project_flock: OptionType<number> | null; project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null; project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
}; };
} }
@@ -79,6 +90,13 @@ const MarketingFilterModal = ({
'search' 'search'
); );
const {
options: warehouseOptions,
isLoadingOptions: isLoadingWarehouseOptions,
setInputValue: setWarehouseInputValue,
loadMore: loadMoreWarehouses,
} = useSelect<Warehouse>(WarehouseApi.basePath, 'id', 'name', 'search');
const statusOptions = [ const statusOptions = [
...MARKETING_APPROVAL_LINE.map((item) => ({ ...MARKETING_APPROVAL_LINE.map((item) => ({
value: item.step_name.split(' ').join('_').toUpperCase(), value: item.step_name.split(' ').join('_').toUpperCase(),
@@ -87,6 +105,13 @@ const MarketingFilterModal = ({
{ value: 'DITOLAK', label: 'Ditolak' }, { 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>({ const formik = useFormik<MarketingFilterFormValues>({
initialValues: initialValues || { initialValues: initialValues || {
product_ids: [], product_ids: [],
@@ -94,6 +119,10 @@ const MarketingFilterModal = ({
customer: null, customer: null,
project_flock: null, project_flock: null,
project_flock_kandang: null, project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
}, },
validationSchema: MarketingFilterSchema, validationSchema: MarketingFilterSchema,
@@ -111,6 +140,12 @@ const MarketingFilterModal = ({
Number(values.project_flock_kandang?.value) || undefined, Number(values.project_flock_kandang?.value) || undefined,
project_flock_kandang_name: project_flock_kandang_name:
values.project_flock_kandang?.label || undefined, 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); onSubmit?.(formattedValues);
@@ -133,12 +168,37 @@ const MarketingFilterModal = ({
customer: null, customer: null,
project_flock: null, project_flock: null,
project_flock_kandang: null, project_flock_kandang: null,
warehouse: null,
start_date: '',
end_date: '',
filter_by: null,
}, },
}); });
setHasDateError(false);
onReset?.(); onReset?.();
closeModalHandler(); closeModalHandler();
}, [resetForm, 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) => { const productChangeHandler = (val: OptionType | OptionType[] | null) => {
formik.setFieldValue('product_ids', val as OptionType[]); formik.setFieldValue('product_ids', val as OptionType[]);
}; };
@@ -207,6 +267,44 @@ const MarketingFilterModal = ({
{/* Modal Body */} {/* Modal Body */}
<div className='p-4 flex flex-col gap-1.5'> <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 */} {/* select multiple product */}
<SelectInputCheckbox <SelectInputCheckbox
label='Product' label='Product'
@@ -272,6 +370,22 @@ const MarketingFilterModal = ({
} }
isDisabled={!formik.values.project_flock} 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> </div>
{/* Modal Footer */} {/* Modal Footer */}
@@ -288,6 +402,7 @@ const MarketingFilterModal = ({
<Button <Button
type='submit' type='submit'
className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm' className='p-3 rounded-lg w-fit sm:w-full max-w-40 text-base-100 text-sm'
disabled={hasDateError}
> >
Apply Filter Apply Filter
</Button> </Button>
@@ -203,6 +203,12 @@ const MarketingTable = () => {
project_flock_name: '', project_flock_name: '',
project_flock_kandang_id: '', project_flock_kandang_id: '',
project_flock_kandang_name: '', project_flock_kandang_name: '',
warehouse_id: '',
warehouse_name: '',
start_date: '',
end_date: '',
filter_by: '',
filter_by_name: '',
sort_by: '', sort_by: '',
order_by: '', order_by: '',
}, },
@@ -214,6 +220,10 @@ const MarketingTable = () => {
customer_id: 'customer_id', customer_id: 'customer_id',
project_flock_id: 'project_flock_id', project_flock_id: 'project_flock_id',
project_flock_kandang_id: 'project_flock_kandang_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', sort_by: 'sort_by',
order_by: 'sort_order', order_by: 'sort_order',
}, },
@@ -223,6 +233,8 @@ const MarketingTable = () => {
'customer_name', 'customer_name',
'project_flock_name', 'project_flock_name',
'project_flock_kandang_name', 'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
], ],
persist: true, persist: true,
@@ -293,6 +305,16 @@ const MarketingTable = () => {
values.project_flock_kandang_name ?? '', values.project_flock_kandang_name ?? '',
true 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] = const [isLoadingExportingToExcel, setIsLoadingExportingToExcel] =
@@ -311,6 +333,12 @@ const MarketingTable = () => {
updateFilter('project_flock_name', '', true); updateFilter('project_flock_name', '', true);
updateFilter('project_flock_kandang_id', '', true); updateFilter('project_flock_kandang_id', '', true);
updateFilter('project_flock_kandang_name', '', 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 = () => { const approveClickHandler = () => {
@@ -433,6 +461,20 @@ const MarketingTable = () => {
label: tableFilterState.project_flock_kandang_name, label: tableFilterState.project_flock_kandang_name,
} }
: null, : 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) => { const approveMarketingHandler = async (notes: string) => {
@@ -707,7 +749,7 @@ const MarketingTable = () => {
}, },
{ {
accessorKey: 'so_date', accessorKey: 'so_date',
header: 'Tanggal', header: 'Tanggal SO',
cell: (props) => { cell: (props) => {
return formatDate(props.row.original.so_date, 'DD MMM yyyy'); return formatDate(props.row.original.so_date, 'DD MMM yyyy');
}, },
@@ -753,18 +795,17 @@ const MarketingTable = () => {
cell: (props) => props.row.original.customer.name, cell: (props) => props.row.original.customer.name,
}, },
{ {
accessorKey: 'grand_total', accessorKey: 'grand_total_so',
accessorFn: (row) => header: 'Grand Total SO',
row.sales_order
?.map((product) => product.total_price)
.reduce((a, b) => a + b, 0) ?? 0,
header: 'Grand Total',
cell: (props) => { cell: (props) => {
return formatCurrency( return formatCurrency(props.row.original?.grand_total_so);
props.row.original?.sales_order },
?.map((product) => product.total_price) },
.reduce((a, b) => a + b, 0) ?? 0 {
); accessorKey: 'grand_total_do',
header: 'Grand Total DO',
cell: (props) => {
return formatCurrency(props.row.original?.grand_total_do);
}, },
}, },
{ {
@@ -911,6 +952,8 @@ const MarketingTable = () => {
'customer_name', 'customer_name',
'project_flock_name', 'project_flock_name',
'project_flock_kandang_name', 'project_flock_kandang_name',
'warehouse_name',
'filter_by_name',
'sort_by', 'sort_by',
'order_by', 'order_by',
]} ]}
@@ -1,4 +1,4 @@
import { array, mixed, object } from 'yup'; import { array, mixed, object, string } from 'yup';
import { OptionType } from '@/components/input/SelectInput'; import { OptionType } from '@/components/input/SelectInput';
export const MarketingFilterSchema = object({ export const MarketingFilterSchema = object({
@@ -7,6 +7,10 @@ export const MarketingFilterSchema = object({
customer: mixed<OptionType<number>>().nullable(), customer: mixed<OptionType<number>>().nullable(),
project_flock: mixed<OptionType<number>>().nullable(), project_flock: mixed<OptionType<number>>().nullable(),
project_flock_kandang: 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 = { export type MarketingFilterFormValues = {
@@ -15,4 +19,8 @@ export type MarketingFilterFormValues = {
customer: OptionType<number> | null; customer: OptionType<number> | null;
project_flock: OptionType<number> | null; project_flock: OptionType<number> | null;
project_flock_kandang: OptionType<number> | null; project_flock_kandang: OptionType<number> | null;
warehouse: OptionType<number> | null;
start_date: string;
end_date: string;
filter_by: OptionType<string> | null;
}; };
@@ -463,13 +463,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
}, [selectedKandang]); }, [selectedKandang]);
// ===== TRANSITION RESTRICTION LOGIC ===== // ===== TRANSITION RESTRICTION LOGIC =====
const isTransitionPeriod = useMemo(() => { // const isTransitionPeriod = useMemo(() => {
return ( // return (
initialValues?.is_transition ?? // initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ?? // projectFlockKandangLookup?.is_transition ??
false // false
); // );
}, [initialValues, projectFlockKandangLookup]); // }, [initialValues, projectFlockKandangLookup]);
// set to false by request: 30 May 2026, 09:11
const isTransitionPeriod = false;
const recordingRestriction = useMemo(() => { const recordingRestriction = useMemo(() => {
let isLaying: boolean; let isLaying: boolean;
@@ -483,10 +486,13 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
false; false;
} }
const isTransition = // const isTransition =
initialValues?.is_transition ?? // initialValues?.is_transition ??
projectFlockKandangLookup?.is_transition ?? // projectFlockKandangLookup?.is_transition ??
false; // false;
// set to false by request: 30 May 2026, 09:11
const isTransition = false;
const currentIsLaying = const currentIsLaying =
type === 'edit' type === 'edit'
@@ -11,49 +11,67 @@ export const getRecordingRestriction = (
isTransition: boolean, isTransition: boolean,
currentIsLaying?: boolean currentIsLaying?: boolean
): RecordingRestriction => { ): RecordingRestriction => {
if (isTransition && !isLaying) { // if (isTransition && !isLaying) {
const isLayingKandangInTransition = currentIsLaying === true; // const isLayingKandangInTransition = currentIsLaying === true;
if (isLayingKandangInTransition) { // if (isLayingKandangInTransition) {
return { // return {
canEditStock: false, // canEditStock: false,
canEditDepletion: true, // canEditDepletion: true,
canEditEgg: true, // canEditEgg: true,
isLocked: false, // isLocked: false,
lockReason: undefined, // lockReason: undefined,
}; // };
} else { // } else {
return { // return {
canEditStock: true, // canEditStock: true,
canEditDepletion: false, // canEditDepletion: false,
canEditEgg: false, // canEditEgg: false,
isLocked: false, // isLocked: false,
lockReason: undefined, // lockReason: undefined,
}; // };
} // }
} // }
if (!isLaying && !isTransition && currentIsLaying) { // if (!isLaying && !isTransition && currentIsLaying) {
return { // return {
canEditStock: false, // canEditStock: false,
canEditDepletion: false, // canEditDepletion: false,
canEditEgg: false, // canEditEgg: false,
isLocked: true, // isLocked: true,
lockReason: // lockReason:
'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying', // 'Recording Growing telah terkunci karena Project Flock sudah masuk fase Laying',
}; // };
} // }
if (!isLaying && !isTransition) { // if (!isLaying && !isTransition) {
return { // return {
canEditStock: true, // canEditStock: true,
canEditDepletion: true, // canEditDepletion: true,
canEditEgg: false, // canEditEgg: false,
isLocked: false, // isLocked: false,
lockReason: undefined, // lockReason: undefined,
}; // };
} // }
if (isLaying && !isTransition) { // 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 { return {
canEditStock: true, canEditStock: true,
canEditDepletion: true, canEditDepletion: true,
@@ -61,13 +79,4 @@ export const getRecordingRestriction = (
isLocked: false, isLocked: false,
lockReason: undefined, lockReason: undefined,
}; };
}
return {
canEditStock: false,
canEditDepletion: false,
canEditEgg: false,
isLocked: true,
lockReason: 'Kondisi transisi tidak valid',
};
}; };
@@ -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: {
+8
View File
@@ -23,6 +23,8 @@ export type BaseMarketing = {
latest_approval: BaseApproval; latest_approval: BaseApproval;
sales_order: BaseSalesOrder[]; sales_order: BaseSalesOrder[];
delivery_order: BaseDeliveryOrder[]; delivery_order: BaseDeliveryOrder[];
grand_total_do: number;
grand_total_so: number;
}; };
export type BaseSalesOrder = { export type BaseSalesOrder = {
@@ -104,6 +106,12 @@ export type MarketingFilter = {
project_flock_name?: string; project_flock_name?: string;
project_flock_kandang_id?: number; project_flock_kandang_id?: number;
project_flock_kandang_name?: string; 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;
};
+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[];
};