+
+
+
+
{!isSelectionValid && {t('common:REQUIRED')}}
-
- {selectedOptions.map((option) =>
- children({
- option,
- system,
- currency,
- fieldPrefix,
- disabledInput,
- }),
- )}
- >
+
+ {selectedOptions.map((option) =>
+ children({
+ option,
+ fieldPrefix,
+ disabledInput,
+ }),
+ )}
+
+
);
}
diff --git a/packages/webapp/src/components/Forms/GeneralRevenue/SaleLineItem.tsx b/packages/webapp/src/components/Forms/RevenueForm/SaleLineItem.tsx
similarity index 100%
rename from packages/webapp/src/components/Forms/GeneralRevenue/SaleLineItem.tsx
rename to packages/webapp/src/components/Forms/RevenueForm/SaleLineItem.tsx
diff --git a/packages/webapp/src/components/Forms/GeneralRevenue/constants.js b/packages/webapp/src/components/Forms/RevenueForm/constants.js
similarity index 91%
rename from packages/webapp/src/components/Forms/GeneralRevenue/constants.js
rename to packages/webapp/src/components/Forms/RevenueForm/constants.js
index 8ff2e31381..0c89554be1 100644
--- a/packages/webapp/src/components/Forms/GeneralRevenue/constants.js
+++ b/packages/webapp/src/components/Forms/RevenueForm/constants.js
@@ -30,3 +30,7 @@ export const CROP_VARIETY_ID = 'crop_variety_id';
export const QUANTITY = 'quantity';
export const QUANTITY_UNIT = 'quantity_unit';
export const SALE_VALUE = 'sale_value';
+
+// animal sale
+export const ANIMAL_SALE = 'animal_sale';
+export const ANIMAL_INVENTORY_ID = 'animal_inventory_id';
diff --git a/packages/webapp/src/components/Forms/GeneralRevenue/index.jsx b/packages/webapp/src/components/Forms/RevenueForm/index.jsx
similarity index 88%
rename from packages/webapp/src/components/Forms/GeneralRevenue/index.jsx
rename to packages/webapp/src/components/Forms/RevenueForm/index.jsx
index 90d55f1d49..e5b6eab5ef 100644
--- a/packages/webapp/src/components/Forms/GeneralRevenue/index.jsx
+++ b/packages/webapp/src/components/Forms/RevenueForm/index.jsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 LiteFarm.org
+ * Copyright 2023-2026 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
@@ -12,7 +12,7 @@
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see
.
*/
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Form from '../../Form';
@@ -35,8 +35,10 @@ import {
REVENUE_TYPE_ID,
} from './constants';
import PropTypes from 'prop-types';
+import EntitySaleInputs from '../../../containers/Finances/EntitySaleInputs';
+import { isAnimalSale, isCropSale } from '../../../containers/Finances/util';
-const GeneralRevenue = ({
+const RevenueForm = ({
onSubmit,
title,
currency,
@@ -48,10 +50,9 @@ const GeneralRevenue = ({
revenueTypeOptions,
onTypeChange,
buttonText,
- customFormChildrenDefaultValues,
- CustomFormChildren,
revenueTypes,
onRetire,
+ entitySaleDefaultValues,
}) => {
const { t } = useTranslation();
const [isDeleting, setIsDeleting] = useState(false);
@@ -70,7 +71,7 @@ const GeneralRevenue = ({
}),
[VALUE]: !isNaN(data[VALUE]) ? data[VALUE] : null,
[NOTE]: data[NOTE] ?? null,
- ...customFormChildrenDefaultValues,
+ ...entitySaleDefaultValues,
},
});
@@ -87,6 +88,13 @@ const GeneralRevenue = ({
const selectedRevenueType = revenueTypes?.find(
(rt) => rt.revenue_type_id === selectedTypeOption?.value,
);
+ const isEntitySale = isCropSale(selectedRevenueType) || isAnimalSale(selectedRevenueType);
+
+ const notesPlaceholder = isCropSale(selectedRevenueType)
+ ? t('SALE.ADD_SALE.CROP_NOTES_PLACEHOLDER')
+ : isAnimalSale(selectedRevenueType)
+ ? t('SALE.ADD_SALE.ANIMAL_NOTES_PLACEHOLDER')
+ : t('SALE.ADD_SALE.NOTES_PLACEHOLDER');
useEffect(() => {
if (revenueTypeOptions?.length && !selectedTypeOption) {
@@ -161,8 +169,10 @@ const GeneralRevenue = ({
style={{ marginBottom: '40px' }}
label={t('LOG_COMMON.NOTES')}
optional={true}
- hookFormRegister={register(NOTE, { maxLength: hookFormMaxCharsValidation(10000) })}
+ hookFormRegister={register(NOTE, { maxLength: hookFormMaxCharsValidation(3000) })}
name={NOTE}
+ placeholder={notesPlaceholder}
+ minRows={5}
errors={getInputErrors(errors, NOTE)}
disabled={disabledInput}
/>
@@ -187,12 +197,11 @@ const GeneralRevenue = ({
)}
/>
)}
- {CustomFormChildren && selectedRevenueType?.entity_type ? (
-
) : (
.
+ */
+
+.entitySalePickerContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.selectorGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.saleItemList {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ margin-top: 8px;
+}
+
+.saleItemContainer {
+ display: flex;
+ gap: 24px;
+}
+
+.saleItemInputGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 200px;
+ flex-grow: 1;
+}
diff --git a/packages/webapp/src/components/Icons/icons.tsx b/packages/webapp/src/components/Icons/icons.tsx
index da92ed6146..91bc8d1b37 100644
--- a/packages/webapp/src/components/Icons/icons.tsx
+++ b/packages/webapp/src/components/Icons/icons.tsx
@@ -22,6 +22,7 @@ import { ReactComponent as ProfitLossIcon } from '../../assets/images/finance/Pr
// Revenue types
import { ReactComponent as CropSaleIcon } from '../../assets/images/finance/Crop-sale-icn.svg';
+import { ReactComponent as AnimalSaleIcon } from '../../assets/images/nav/animals.svg';
import { ReactComponent as CustomTypeIcon } from '../../assets/images/finance/Custom-revenue.svg';
// Expense types
@@ -116,6 +117,7 @@ export const iconMap = {
PROFIT_LOSS: ProfitLossIcon,
// Revenue types
CROP_SALE: CropSaleIcon,
+ ANIMAL_SALE: AnimalSaleIcon,
CUSTOM: CustomTypeIcon,
// Expense types
EQUIPMENT: EquipIcon,
diff --git a/packages/webapp/src/components/RevisionInfoText.tsx b/packages/webapp/src/components/RevisionInfoText.tsx
index 2c43774f1f..85ed7cefc1 100644
--- a/packages/webapp/src/components/RevisionInfoText.tsx
+++ b/packages/webapp/src/components/RevisionInfoText.tsx
@@ -14,7 +14,7 @@
*/
import { Trans } from 'react-i18next';
-import { getFirstNameWithLastInitial } from '../util';
+import { getFirstNameWithLastInitial } from '../util/getFirstNameWithLastInitial';
import { getIntlDate } from '../util/date-migrate-TS';
/**
diff --git a/packages/webapp/src/containers/Finances/ActualRevenue/index.jsx b/packages/webapp/src/containers/Finances/ActualRevenue/index.jsx
index 7327565745..78fb7f564d 100644
--- a/packages/webapp/src/containers/Finances/ActualRevenue/index.jsx
+++ b/packages/webapp/src/containers/Finances/ActualRevenue/index.jsx
@@ -27,6 +27,7 @@ import {
FINANCES_HOME_URL,
REVENUE_TYPES_URL,
} from '../../../util/siteMapConstants';
+import { useGetAnimalBatchesQuery, useGetAnimalsQuery } from '../../../store/api/apiSlice';
export default function ActualRevenue() {
const history = useHistory();
@@ -41,6 +42,8 @@ export default function ActualRevenue() {
const sales = useSelector(salesSelector);
const allRevenueTypes = useSelector(allRevenueTypesSelector);
const cropVarieties = useSelector(cropVarietiesSelector);
+ const { data: animals } = useGetAnimalsQuery();
+ const { data: animalBatches } = useGetAnimalBatchesQuery();
const { startDate: fromDate, endDate: toDate } = useFinancesDateRange({ weekStartDate: SUNDAY });
const filteredSales = useMemo(
@@ -48,8 +51,9 @@ export default function ActualRevenue() {
[sales, fromDate, toDate],
);
const revenueItems = useMemo(
- () => mapSalesToRevenueItems(filteredSales, allRevenueTypes, cropVarieties),
- [filteredSales, allRevenueTypes, cropVarieties],
+ () =>
+ mapSalesToRevenueItems(filteredSales, allRevenueTypes, cropVarieties, animals, animalBatches),
+ [filteredSales, allRevenueTypes, cropVarieties, animals, animalBatches],
);
const revenueForWholeFarm = useMemo(
() => calcActualRevenueFromRevenueItems(revenueItems),
diff --git a/packages/webapp/src/containers/Finances/AddSale/index.jsx b/packages/webapp/src/containers/Finances/AddSale/index.jsx
index 939d9bda59..f2081ba587 100644
--- a/packages/webapp/src/containers/Finances/AddSale/index.jsx
+++ b/packages/webapp/src/containers/Finances/AddSale/index.jsx
@@ -13,9 +13,7 @@
* GNU General Public License for more details, see
.
*/
-import React from 'react';
-import GeneralRevenue from '../../../components/Forms/GeneralRevenue';
-import CropSaleInputs from '../CropSaleInputs';
+import RevenueForm from '../../../components/Forms/RevenueForm';
import { addSale } from '../actions';
import { userFarmSelector } from '../../userFarmSlice';
import { useDispatch, useSelector } from 'react-redux';
@@ -52,14 +50,13 @@ function AddSale() {
return (
- .
+ */
+
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { useTranslation } from 'react-i18next';
+import { ANIMAL_INVENTORY_ID, ANIMAL_SALE } from '../../../components/Forms/RevenueForm/constants';
+import AnimalSaleItem from '../../../components/Forms/RevenueForm/AnimalSaleItem';
+import EntitySalePicker from '../../../components/Forms/RevenueForm/EntitySalePicker';
+import { useGetAnimalsQuery, useGetAnimalBatchesQuery } from '../../../store/api/apiSlice';
+import { measurementSelector } from '../../userFarmSlice';
+import { useCurrencySymbol } from '../../hooks/useCurrencySymbol';
+import { chooseIdentification } from '../../Animals/utils';
+import { getUnitOptionMap } from '../../../util/convert-units/getUnitOptionMap';
+import { generateInventoryId } from '../../../util/animal';
+import type { Animal, AnimalBatch } from '../../../store/api/types';
+import { AnimalOrBatchKeys } from '../../Animals/types';
+import type { SelectOption } from '../../../components/Form/ReactSelect/CheckboxMultiSelect';
+
+interface BaseAnimalSaleRecord {
+ animal_id: number | null;
+ animal_batch_id: number | null;
+ quantity: number;
+ quantity_unit: TQuantityUnit;
+ sale_value: number;
+}
+
+// API data returns a string for quantity_unit, but form data uses SelectOption
+type AnimalSaleRecord = BaseAnimalSaleRecord;
+type AnimalSaleDefaultRecord = BaseAnimalSaleRecord;
+
+export interface AnimalSale {
+ animal_sale?: AnimalSaleRecord[];
+}
+
+interface AnimalSaleInputsProps {
+ sale?: AnimalSale;
+ disabledInput: boolean;
+}
+
+const saleRecordToOptionKey = (record: AnimalSaleRecord) => {
+ const isAnimal = record.animal_id !== null;
+ const key = isAnimal ? AnimalOrBatchKeys.ANIMAL : AnimalOrBatchKeys.BATCH;
+ const id = isAnimal ? record.animal_id : record.animal_batch_id;
+
+ return `${key}_${id}`;
+};
+
+export const getAnimalSaleDefaultValues = (sale: AnimalSale | undefined) => {
+ if (!sale?.animal_sale) {
+ return { [ANIMAL_SALE]: undefined };
+ }
+
+ const unitMap = getUnitOptionMap() as Record;
+
+ const existingSales = Object.fromEntries(
+ sale.animal_sale.map((record) => {
+ const key = saleRecordToOptionKey(record);
+ const unit = record.quantity_unit;
+ const formattedEntry: AnimalSaleDefaultRecord = {
+ ...record,
+ quantity_unit: unit ? (unitMap[unit] ?? { label: unit, value: unit }) : undefined,
+ };
+ return [key, formattedEntry];
+ }),
+ );
+
+ return {
+ [ANIMAL_SALE]: existingSales,
+ };
+};
+
+export default function AnimalSaleInputs({ sale, disabledInput }: AnimalSaleInputsProps) {
+ const { t } = useTranslation();
+ const system = useSelector(measurementSelector);
+ const currency = useCurrencySymbol();
+ const { data: animals } = useGetAnimalsQuery();
+ const { data: animalBatches } = useGetAnimalBatchesQuery();
+
+ const options = useMemo(() => {
+ const animalOptions = (animals ?? []).map((a: Animal) => ({
+ label: chooseIdentification(a),
+ value: generateInventoryId(AnimalOrBatchKeys.ANIMAL, a),
+ }));
+ const batchOptions = (animalBatches ?? []).map((b: AnimalBatch) => ({
+ label: chooseIdentification(b),
+ value: generateInventoryId(AnimalOrBatchKeys.BATCH, b),
+ }));
+ return [...animalOptions, ...batchOptions].sort((a, b) =>
+ String(a.label).localeCompare(String(b.label)),
+ );
+ }, [animals, animalBatches]);
+
+ const savedSalesById = sale?.animal_sale?.reduce>(
+ (acc, cur) => ({ ...acc, [saleRecordToOptionKey(cur)]: cur }),
+ {},
+ );
+
+ return (
+
+ {({ option, disabledInput }) => (
+
+ )}
+
+ );
+}
diff --git a/packages/webapp/src/containers/Finances/CropSaleInputs.tsx b/packages/webapp/src/containers/Finances/EntitySaleInputs/CropSaleInputs.tsx
similarity index 79%
rename from packages/webapp/src/containers/Finances/CropSaleInputs.tsx
rename to packages/webapp/src/containers/Finances/EntitySaleInputs/CropSaleInputs.tsx
index f5c8a40292..9f8e556150 100644
--- a/packages/webapp/src/containers/Finances/CropSaleInputs.tsx
+++ b/packages/webapp/src/containers/Finances/EntitySaleInputs/CropSaleInputs.tsx
@@ -19,13 +19,15 @@ import { useTranslation } from 'react-i18next';
import {
CROP_VARIETY_SALE,
CROP_VARIETY_ID,
-} from '../../components/Forms/GeneralRevenue/constants';
-import CropSaleItem from '../../components/Forms/GeneralRevenue/CropSaleItem';
-import { selectManagementPlansForSale } from '../managementPlanSlice';
-import EntitySaleInputs from './EntitySaleInputs';
-import type { CropVarietySaleTileData } from '../../components/CropTile/CropVarietySaleTile';
-import { getUnitOptionMap } from '../../util/convert-units/getUnitOptionMap';
-import type { SelectOption } from '../../components/Form/ReactSelect/CheckboxMultiSelect/index';
+} from '../../../components/Forms/RevenueForm/constants';
+import CropSaleItem from '../../../components/Forms/RevenueForm/CropSaleItem';
+import { selectManagementPlansForSale } from '../../managementPlanSlice';
+import { measurementSelector } from '../../userFarmSlice';
+import { useCurrencySymbol } from '../../hooks/useCurrencySymbol';
+import EntitySalePicker from '../../../components/Forms/RevenueForm/EntitySalePicker';
+import type { CropVarietySaleTileData } from '../../../components/CropTile/CropVarietySaleTile';
+import { getUnitOptionMap } from '../../../util/convert-units/getUnitOptionMap';
+import type { SelectOption } from '../../../components/Form/ReactSelect/CheckboxMultiSelect/index';
export const getCropSaleDefaultValues = (sale: CropSale | undefined) => {
const existingSales = sale?.crop_variety_sale?.reduce<
@@ -59,7 +61,7 @@ interface CropVarietySaleRecord {
sale_value: number;
}
-interface CropSale {
+export interface CropSale {
crop_variety_sale?: CropVarietySaleRecord[];
}
@@ -70,6 +72,8 @@ interface CropSaleInputsProps {
export default function CropSaleInputs({ sale, disabledInput }: CropSaleInputsProps) {
const { t } = useTranslation();
+ const system = useSelector(measurementSelector);
+ const currency = useCurrencySymbol();
const managementPlans = useSelector((state) =>
selectManagementPlansForSale(state, sale?.crop_variety_sale),
);
@@ -104,15 +108,16 @@ export default function CropSaleInputs({ sale, disabledInput }: CropSaleInputsPr
);
return (
-
- {({ option, system, currency, disabledInput }) => (
+ {({ option, disabledInput }) => (
)}
-
+
);
}
diff --git a/packages/webapp/src/containers/Finances/EntitySaleInputs/index.tsx b/packages/webapp/src/containers/Finances/EntitySaleInputs/index.tsx
new file mode 100644
index 0000000000..b65ccb3c19
--- /dev/null
+++ b/packages/webapp/src/containers/Finances/EntitySaleInputs/index.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import CropSaleInputs, { CropSale, getCropSaleDefaultValues } from './CropSaleInputs';
+import AnimalSaleInputs, { AnimalSale, getAnimalSaleDefaultValues } from './AnimalSaleInputs';
+import type { EntityType } from '../types';
+
+type EntitySale = CropSale | AnimalSale;
+
+interface EntitySaleInputsProps {
+ sale?: EntitySale;
+ disabledInput: boolean;
+ entityType?: EntityType;
+}
+
+export const getEntityTypeDefaultValues = (
+ sale: EntitySaleInputsProps['sale'],
+ entityType: EntityType,
+) => {
+ if (entityType === 'crop') {
+ return getCropSaleDefaultValues(sale as CropSale);
+ }
+ if (entityType === 'animal') {
+ return getAnimalSaleDefaultValues(sale as AnimalSale);
+ }
+ return undefined;
+};
+
+export default function EntitySaleInputs({
+ sale,
+ disabledInput,
+ entityType,
+}: EntitySaleInputsProps) {
+ if (entityType === 'crop') {
+ return ;
+ }
+ if (entityType === 'animal') {
+ return ;
+ }
+ return null;
+}
diff --git a/packages/webapp/src/containers/Finances/RevenueDetail/index.jsx b/packages/webapp/src/containers/Finances/RevenueDetail/index.jsx
index 9a8473b146..f30c93c8e1 100644
--- a/packages/webapp/src/containers/Finances/RevenueDetail/index.jsx
+++ b/packages/webapp/src/containers/Finances/RevenueDetail/index.jsx
@@ -22,16 +22,12 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useCurrencySymbol } from '../../hooks/useCurrencySymbol';
import { setPersistedPaths } from '../../hooks/useHookFormPersist/hookFormPersistSlice';
-import GeneralRevenue from '../../../components/Forms/GeneralRevenue';
-import CropSaleInputs, { getCropSaleDefaultValues } from '../CropSaleInputs';
+import RevenueForm from '../../../components/Forms/RevenueForm';
import useHookFormPersist from '../../hooks/useHookFormPersist';
-import {
- isCropSale,
- mapRevenueFormDataToApiCallFormat,
- mapRevenueTypesToReactSelectOptions,
-} from '../util';
+import { mapRevenueFormDataToApiCallFormat, mapRevenueTypesToReactSelectOptions } from '../util';
import useSortedRevenueTypes from '../AddSale/RevenueTypes/useSortedRevenueTypes';
-import { REVENUE_TYPE_OPTION } from '../../../components/Forms/GeneralRevenue/constants';
+import { getEntityTypeDefaultValues } from '../EntitySaleInputs';
+import { REVENUE_TYPE_OPTION } from '../../../components/Forms/RevenueForm/constants';
import { createEditRevenueDetailsUrl } from '../../../util/siteMapConstants';
function RevenueDetail() {
@@ -81,17 +77,15 @@ function RevenueDetail() {
setValue(REVENUE_TYPE_OPTION, newType);
};
+ const entitySaleDefaultValues = getEntityTypeDefaultValues(sale, revenueType?.entity_type);
+
return (
-
);
}
diff --git a/packages/webapp/src/containers/Finances/constants.js b/packages/webapp/src/containers/Finances/constants.js
index 9ef9152f0a..8814c5e5d8 100644
--- a/packages/webapp/src/containers/Finances/constants.js
+++ b/packages/webapp/src/containers/Finances/constants.js
@@ -33,11 +33,6 @@ export const UPDATE_SALE = 'UPDATE_SALE';
export const DELETE_EXPENSE = 'DELETE_EXPENSE';
export const SET_IS_FETCHING_DATA = 'SET_IS_FETCHING_DATA';
-export const REVENUE_FORM_TYPES = {
- CROP_SALE: 'crop_sale',
- GENERAL: 'general',
-};
-
export const LABOUR_ITEMS_GROUPING_OPTIONS = {
EMPLOYEE: 'EMPLOYEE',
TASK_TYPE: 'TASK_TYPE',
diff --git a/packages/webapp/src/containers/Finances/saga.js b/packages/webapp/src/containers/Finances/saga.js
index f7c634f659..18af899bb6 100644
--- a/packages/webapp/src/containers/Finances/saga.js
+++ b/packages/webapp/src/containers/Finances/saga.js
@@ -367,13 +367,7 @@ export function* getRevenueTypesSaga() {
try {
const result = yield call(axios.get, `${revenueTypeUrl}/farm/${farm_id}`, header);
-
- // TODO LF-5274: Remove filter when ANIMAL_SALE is supported
- const formattedResult = result.data.filter(({ farm_id, revenue_translation_key }) => {
- return !!farm_id || revenue_translation_key !== 'ANIMAL_SALE';
- });
-
- yield put(getRevenueTypesSuccess(formattedResult));
+ yield put(getRevenueTypesSuccess(result.data));
} catch (e) {
console.log('failed to fetch revenue types from database');
}
diff --git a/packages/webapp/src/containers/Finances/useTransactions.js b/packages/webapp/src/containers/Finances/useTransactions.js
index 068264c6a9..baa3374deb 100644
--- a/packages/webapp/src/containers/Finances/useTransactions.js
+++ b/packages/webapp/src/containers/Finances/useTransactions.js
@@ -25,15 +25,17 @@ import { allRevenueTypesSelector } from '../revenueTypeSlice';
import { tasksSelector } from '../taskSlice';
import { taskTypesSelector } from '../taskTypeSlice';
import { userFarmsByFarmSelector } from '../userFarmSlice';
+import { useGetAnimalsQuery, useGetAnimalBatchesQuery } from '../../store/api/apiSlice';
import { LABOUR_ITEMS_GROUPING_OPTIONS } from './constants';
import { allExpenseTypeSelector, expenseSelector, salesSelector } from './selectors';
-import { isCropSale, mapSalesToRevenueItems, mapTasksToLabourItems } from './util';
+import { isAnimalSale, isCropSale, mapSalesToRevenueItems, mapTasksToLabourItems } from './util';
export const transactionTypeEnum = {
expense: 'EXPENSE',
labourExpense: 'LABOUR_EXPENSE',
revenue: 'REVENUE',
cropRevenue: 'CROP_REVENUE',
+ animalRevenue: 'ANIMAL_REVENUE',
};
// Polyfill for tests and older browsers
@@ -116,7 +118,7 @@ const buildExpenseTransactions = ({ expenses, expenseTypes, dateFilter, expenseT
(expenseType) => expenseType?.expense_type_id === expense.expense_type_id,
);
return {
- icon: expenseType?.farm_id ? 'OTHER' : expenseType?.expense_translation_key ?? 'OTHER',
+ icon: expenseType?.farm_id ? 'OTHER' : (expenseType?.expense_translation_key ?? 'OTHER'),
date: expense.expense_date,
transactionType: transactionTypeEnum.expense,
typeLabel: getExpenseTypeLabel(expenseType),
@@ -127,10 +129,22 @@ const buildExpenseTransactions = ({ expenses, expenseTypes, dateFilter, expenseT
});
};
+const getRevenueTransactionType = (revenueType) => {
+ if (isCropSale(revenueType)) {
+ return transactionTypeEnum.cropRevenue;
+ }
+ if (isAnimalSale(revenueType)) {
+ return transactionTypeEnum.animalRevenue;
+ }
+ return transactionTypeEnum.revenue;
+};
+
const buildRevenueTransactions = ({
sales,
revenueTypes,
cropVarieties,
+ animals,
+ animalBatches,
dateFilter,
revenueTypeFilter,
}) => {
@@ -141,18 +155,22 @@ const buildRevenueTransactions = ({
moment(sale.sale_date).isSameOrBefore(dateFilter.endDate, 'day'))) &&
(!revenueTypeFilter || revenueTypeFilter[sale.revenue_type_id]?.active),
);
- const revenueItems = mapSalesToRevenueItems(filteredSales, revenueTypes, cropVarieties);
+ const revenueItems = mapSalesToRevenueItems(
+ filteredSales,
+ revenueTypes,
+ cropVarieties,
+ animals,
+ animalBatches,
+ );
return revenueItems.map((item) => {
const revenueType = revenueTypes.find(
(revenueType) => revenueType?.revenue_type_id == item.sale.revenue_type_id,
);
return {
- icon: revenueType?.farm_id ? 'CUSTOM' : revenueType?.revenue_translation_key ?? 'CUSTOM',
+ icon: revenueType?.farm_id ? 'CUSTOM' : (revenueType?.revenue_translation_key ?? 'CUSTOM'),
date: item.sale.sale_date,
- transactionType: isCropSale(revenueType)
- ? transactionTypeEnum.cropRevenue
- : transactionTypeEnum.revenue,
+ transactionType: getRevenueTransactionType(revenueType),
typeLabel: getRevenueTypeLabel(revenueType),
amount: item.totalAmount,
note: item.sale.customer_name,
@@ -170,6 +188,8 @@ export const buildTransactions = ({
revenueTypes = [],
taskTypes = [],
cropVarieties = [],
+ animals = [],
+ animalBatches = [],
users = [],
dateFilter,
expenseTypeFilter,
@@ -188,6 +208,8 @@ export const buildTransactions = ({
sales,
revenueTypes,
cropVarieties,
+ animals,
+ animalBatches,
dateFilter,
revenueTypeFilter,
}),
@@ -207,6 +229,8 @@ const useTransactions = ({ dateFilter, expenseTypeFilter, revenueTypeFilter }) =
const taskTypes = useSelector(taskTypesSelector);
const cropVarieties = useSelector(cropVarietiesSelector);
const users = useSelector(userFarmsByFarmSelector);
+ const { data: animals } = useGetAnimalsQuery();
+ const { data: animalBatches } = useGetAnimalBatchesQuery();
const transactions = useMemo(() => {
if (!expenseTypes?.length || !revenueTypes?.length) {
@@ -221,6 +245,8 @@ const useTransactions = ({ dateFilter, expenseTypeFilter, revenueTypeFilter }) =
revenueTypes,
taskTypes,
cropVarieties,
+ animals,
+ animalBatches,
users,
dateFilter,
expenseTypeFilter,
@@ -234,6 +260,8 @@ const useTransactions = ({ dateFilter, expenseTypeFilter, revenueTypeFilter }) =
revenueTypes,
taskTypes,
cropVarieties,
+ animals,
+ animalBatches,
users,
buildTransactions,
dateFilter,
diff --git a/packages/webapp/src/containers/Finances/util.js b/packages/webapp/src/containers/Finances/util.js
index 7f5d0c54d6..f3bb8aba5a 100644
--- a/packages/webapp/src/containers/Finances/util.js
+++ b/packages/webapp/src/containers/Finances/util.js
@@ -17,6 +17,8 @@ import { groupBy as lodashGroupBy } from 'lodash-es';
import moment from 'moment';
import { useTranslation } from 'react-i18next';
import {
+ ANIMAL_INVENTORY_ID,
+ ANIMAL_SALE,
CROP_VARIETY_ID,
CROP_VARIETY_SALE,
CUSTOMER_NAME,
@@ -27,13 +29,16 @@ import {
SALE_DATE,
SALE_VALUE,
VALUE,
-} from '../../components/Forms/GeneralRevenue/constants';
+} from '../../components/Forms/RevenueForm/constants';
+import { chooseIdentification } from '../Animals/utils';
import i18n from '../../locales/i18n';
import { getMass, getMassUnit, roundToTwoDecimal } from '../../util';
import { isSameDay } from '../../util/date-migrate-TS';
import { getLanguageFromLocalStorage } from '../../util/getLanguageFromLocalStorage';
-import { LABOUR_ITEMS_GROUPING_OPTIONS, REVENUE_FORM_TYPES } from './constants';
+import { LABOUR_ITEMS_GROUPING_OPTIONS } from './constants';
import { transactionTypeEnum } from './useTransactions';
+import { parseInventoryId } from '../../util/animal';
+import { AnimalOrBatchKeys } from '../Animals/types';
// Polyfill for tests and older browsers
const groupBy = typeof Object.groupBy === 'function' ? Object.groupBy : lodashGroupBy;
@@ -94,12 +99,6 @@ export function calcActualRevenueFromRevenueItems(revenueItems) {
return revenueItems.reduce((sum, curItem) => sum + curItem.totalAmount, 0);
}
-export const getRevenueFormType = (revenueType) => {
- return revenueType?.entity_type === 'crop'
- ? REVENUE_FORM_TYPES.CROP_SALE
- : REVENUE_FORM_TYPES.GENERAL;
-};
-
export const mapTasksToLabourItems = (tasks, taskTypes, users) => {
const groupingOptions = [
{
@@ -157,7 +156,13 @@ export const mapTasksToLabourItems = (tasks, taskTypes, users) => {
return labourItemGroups;
};
-export const mapSalesToRevenueItems = (sales, revenueTypes, cropVarieties) => {
+export const mapSalesToRevenueItems = (
+ sales,
+ revenueTypes,
+ cropVarieties,
+ animals = [],
+ animalBatches = [],
+) => {
const revenueItems = sales.map((sale) => {
const revenueType = revenueTypes.find(
(revenueType) => revenueType.revenue_type_id === sale.revenue_type_id,
@@ -168,25 +173,56 @@ export const mapSalesToRevenueItems = (sales, revenueTypes, cropVarieties) => {
return {
sale,
totalAmount: cropVarietySale.reduce((total, sale) => total + sale.sale_value, 0),
- financeItemsProps: cropVarietySale.map((cvs) => {
- const convertedQuantity = roundToTwoDecimal(getMass(cvs.quantity).toString());
- const cropVariety = cropVarieties.find(
- (cropVariety) => cropVariety.crop_variety_id === cvs.crop_variety_id,
- );
- const cropVarietyName = cropVariety?.crop_variety_name;
- const cropTranslationKey = cropVariety?.crop.crop_translation_key;
- const title = cropVarietyName
- ? `${cropVarietyName}, ${i18n.t(`crop:${cropTranslationKey}`)}`
- : i18n.t(`crop:${cropTranslationKey}`);
- return {
- key: cvs.crop_variety_id,
- title,
- subtitle: `${convertedQuantity} ${quantityUnit}`,
- quantity: convertedQuantity,
- quantityUnit,
- amount: cvs.sale_value,
- };
- }),
+ financeItemsProps: cropVarietySale
+ .map((cvs) => {
+ const convertedQuantity = roundToTwoDecimal(getMass(cvs.quantity).toString());
+ const cropVariety = cropVarieties.find(
+ (cropVariety) => cropVariety.crop_variety_id === cvs.crop_variety_id,
+ );
+ const cropVarietyName = cropVariety?.crop_variety_name;
+ const cropTranslationKey = cropVariety?.crop.crop_translation_key;
+ const title = cropVarietyName
+ ? `${cropVarietyName}, ${i18n.t(`crop:${cropTranslationKey}`)}`
+ : i18n.t(`crop:${cropTranslationKey}`);
+ return {
+ key: cvs.crop_variety_id,
+ title,
+ subtitle: `${convertedQuantity} ${quantityUnit}`,
+ quantity: convertedQuantity,
+ quantityUnit,
+ amount: cvs.sale_value,
+ };
+ })
+ .sort((a, b) => String(a.title).localeCompare(String(b.title))),
+ };
+ } else if (revenueType?.entity_type === 'animal') {
+ const quantityUnit = getMassUnit();
+ const animalSale = sale.animal_sale ?? [];
+ return {
+ sale,
+ totalAmount: animalSale.reduce((total, row) => total + row.sale_value, 0),
+ financeItemsProps: animalSale
+ .map((row) => {
+ const convertedQuantity = roundToTwoDecimal(getMass(row.quantity).toString());
+ const matched =
+ row.animal_id != null
+ ? animals.find((a) => a.id === row.animal_id)
+ : animalBatches.find((b) => b.id === row.animal_batch_id);
+ const title = matched
+ ? chooseIdentification(matched)
+ : (row.animal_id ?? row.animal_batch_id);
+ const key =
+ row.animal_id != null ? `animal_${row.animal_id}` : `batch_${row.animal_batch_id}`;
+ return {
+ key,
+ title,
+ subtitle: `${convertedQuantity} ${quantityUnit}`,
+ quantity: convertedQuantity,
+ quantityUnit,
+ amount: row.sale_value,
+ };
+ })
+ .sort((a, b) => String(a.title).localeCompare(String(b.title))),
};
} else {
return {
@@ -242,6 +278,19 @@ export function mapRevenueFormDataToApiCallFormat(data, revenueTypes, sale_id, f
crop_variety_id: c[CROP_VARIETY_ID],
};
});
+ } else if (revenueType?.entity_type === 'animal') {
+ sale.value = undefined;
+ sale.animal_sale = Object.values(data[ANIMAL_SALE]).map((a) => {
+ const { kind, id } = parseInventoryId(a[ANIMAL_INVENTORY_ID]);
+ const isBatch = kind === AnimalOrBatchKeys.BATCH;
+ return {
+ sale_value: a[SALE_VALUE],
+ quantity: a[QUANTITY],
+ quantity_unit: a[QUANTITY_UNIT].label,
+ animal_id: isBatch ? null : id,
+ animal_batch_id: isBatch ? id : null,
+ };
+ });
} else {
sale.crop_variety_sale = undefined;
sale.value = data[VALUE];
@@ -291,3 +340,4 @@ export const getFinanceTypeSearchableStringFunc = (typeCategory) => (type) => {
};
export const isCropSale = (revenueType) => revenueType?.entity_type === 'crop';
+export const isAnimalSale = (revenueType) => revenueType?.entity_type === 'animal';
diff --git a/packages/webapp/src/containers/userFarmSlice.ts b/packages/webapp/src/containers/userFarmSlice.ts
index e4c40519ac..274d44e207 100644
--- a/packages/webapp/src/containers/userFarmSlice.ts
+++ b/packages/webapp/src/containers/userFarmSlice.ts
@@ -4,7 +4,7 @@ import { createSelector } from 'reselect';
import type { RootState } from '../store/store';
import { AxiosError } from 'axios';
import { CONSENT_VERSION } from '../util/constants';
-import { getFirstNameWithLastInitial } from '../util';
+import { getFirstNameWithLastInitial } from '../util/getFirstNameWithLastInitial';
export interface Units {
currency: string;
diff --git a/packages/webapp/src/stories/Finances/CropSaleItem.stories.jsx b/packages/webapp/src/stories/Finances/CropSaleItem.stories.jsx
index 9120bc21ac..0878fc24e1 100644
--- a/packages/webapp/src/stories/Finances/CropSaleItem.stories.jsx
+++ b/packages/webapp/src/stories/Finances/CropSaleItem.stories.jsx
@@ -12,7 +12,7 @@
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see .
*/
-import CropSaleItem from '../../components/Forms/GeneralRevenue/CropSaleItem';
+import CropSaleItem from '../../components/Forms/RevenueForm/CropSaleItem';
import { componentDecorators } from '../Pages/config/Decorators';
import { FormProvider, useForm } from 'react-hook-form';
diff --git a/packages/webapp/src/stories/Finances/GeneralIncome.stories.jsx b/packages/webapp/src/stories/Finances/RevenueForm.stories.jsx
similarity index 54%
rename from packages/webapp/src/stories/Finances/GeneralIncome.stories.jsx
rename to packages/webapp/src/stories/Finances/RevenueForm.stories.jsx
index 9d954cbe10..2f7302c78d 100644
--- a/packages/webapp/src/stories/Finances/GeneralIncome.stories.jsx
+++ b/packages/webapp/src/stories/Finances/RevenueForm.stories.jsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 LiteFarm.org
+ * Copyright 2023-2026 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
@@ -12,11 +12,10 @@
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see .
*/
-import GeneralRevenue from '../../components/Forms/GeneralRevenue';
+import { useState } from 'react';
+import RevenueForm from '../../components/Forms/RevenueForm';
+import { getEntityTypeDefaultValues } from '../../containers/Finances/EntitySaleInputs';
import { componentDecorators } from '../Pages/config/Decorators';
-import React, { useState } from 'react';
-import CropSaleInputs, { getCropSaleDefaultValues } from '../../containers/Finances/CropSaleInputs';
-import { isCropSale } from '../../containers/Finances/util';
const cropSale = {
sale_id: 17,
@@ -53,6 +52,31 @@ const generalSale = {
note: 'hya',
};
+const animalSale = {
+ sale_id: 23,
+ customer_name: 'Animal customer',
+ sale_date: '2023-10-15T04:00:00.000Z',
+ farm_id: null,
+ revenue_type_id: 3,
+ note: 'animal note',
+ animal_sale: [
+ {
+ animal_id: 101,
+ animal_batch_id: null,
+ quantity: 1,
+ sale_value: 250,
+ quantity_unit: 'unit',
+ },
+ {
+ animal_id: null,
+ animal_batch_id: 7,
+ quantity: 10,
+ sale_value: 500,
+ quantity_unit: 'unit',
+ },
+ ],
+};
+
const revenueTypes = [
{
revenue_type_id: 1,
@@ -70,6 +94,14 @@ const revenueTypes = [
deleted: false,
entity_type: null,
},
+ {
+ revenue_type_id: 3,
+ revenue_name: 'Animal Sale',
+ revenue_translation_key: 'ANIMAL_SALE',
+ farm_id: null,
+ deleted: false,
+ entity_type: 'animal',
+ },
];
const revenueTypeOptions = [
{
@@ -80,108 +112,111 @@ const revenueTypeOptions = [
value: 2,
label: 'General Sale',
},
+ {
+ value: 3,
+ label: 'Animal Sale',
+ },
];
-const GeneralRevenueWithState = (props) => {
- const { view, sale, revenueType } = props;
-
+const RevenueFormWithState = (props) => {
+ const { view } = props;
const [isEditing, setIsEditing] = useState(false);
- const [selectedRevenueType, setSelectedRevenueType] = useState(revenueType);
- const onTypeChange = (typeId, setValue, REVENUE_TYPE_OPTION) => {
- const newType = revenueTypes.find((option) => option.value === typeId);
- setValue(REVENUE_TYPE_OPTION, newType);
- };
if (view === 'add') {
- // TODO LF-5274 update passed component
- return ;
- } else {
- return (
- setIsEditing(false) : () => {}}
- onClick={isEditing ? undefined : () => setIsEditing(true)}
- buttonText={isEditing ? 'Save' : 'Edit'}
- // TODO LF-5274 update passed component
- CustomFormChildren={CropSaleInputs}
- customFormChildrenDefaultValues={
- isCropSale(selectedRevenueType) ? getCropSaleDefaultValues(sale) : undefined
- }
- onTypeChange={onTypeChange}
- revenueType={selectedRevenueType}
- {...props}
- />
- );
+ return ;
}
+ return (
+ setIsEditing(false) : () => {}}
+ onClick={isEditing ? undefined : () => setIsEditing(true)}
+ buttonText={isEditing ? 'Save' : 'Edit'}
+ {...props}
+ />
+ );
};
export default {
- title: 'Components/GeneralRevenue',
- component: GeneralRevenueWithState,
+ title: 'Components/RevenueForm',
+ component: RevenueFormWithState,
decorators: componentDecorators,
};
-const Template = (args) => ;
+const Template = (args) => ;
export const AddCropSale = Template.bind({});
AddCropSale.args = {
onSubmit: console.log,
title: 'Add crop sale',
- dateLabel: 'Date',
- //useHookFormPersist: () => ({}),
currency: '$',
view: 'add',
handleGoBack: () => {},
buttonText: 'Save',
- revenueType: revenueTypes[0],
revenueTypes,
persistedFormData: { revenue_type_id: 1 },
revenueTypeOptions,
};
+export const AddAnimalSale = Template.bind({});
+
+AddAnimalSale.args = {
+ onSubmit: console.log,
+ title: 'Add animal sale',
+ currency: '$',
+ view: 'add',
+ handleGoBack: () => {},
+ buttonText: 'Save',
+ revenueTypes,
+ persistedFormData: { revenue_type_id: 3 },
+ revenueTypeOptions,
+};
+
export const AddGeneralSale = Template.bind({});
AddGeneralSale.args = {
onSubmit: console.log,
title: 'Add general sale',
- dateLabel: 'Date',
- //useHookFormPersist: () => ({}),
currency: '$',
view: 'add',
handleGoBack: () => {},
buttonText: 'Save',
- revenueType: revenueTypes[1],
revenueTypes,
persistedFormData: { revenue_type_id: 2 },
revenueTypeOptions,
};
-export const DetailGeneralSale = Template.bind({});
+export const GeneralSaleDetail = Template.bind({});
-DetailGeneralSale.args = {
+GeneralSaleDetail.args = {
title: 'General sale detail',
- dateLabel: 'Date',
- //useHookFormPersist: () => ({}),
currency: '$',
sale: generalSale,
revenueTypeOptions,
onRetire: () => {},
- revenueType: revenueTypes[1],
revenueTypes,
};
-export const DetailCropSale = Template.bind({});
-DetailCropSale.args = {
- title: 'General sale detail',
- dateLabel: 'Date',
- //useHookFormPersist: () => ({}),
+export const CropSaleDetail = Template.bind({});
+CropSaleDetail.args = {
+ title: 'Crop sale detail',
currency: '$',
sale: cropSale,
revenueTypeOptions,
onRetire: () => {},
- revenueType: revenueTypes[0],
revenueTypes,
+ entitySaleDefaultValues: getEntityTypeDefaultValues(cropSale, 'crop'),
+};
+
+export const AnimalSaleDetail = Template.bind({});
+AnimalSaleDetail.args = {
+ title: 'Animal sale detail',
+ currency: '$',
+ sale: animalSale,
+ revenueTypeOptions,
+ onRetire: () => {},
+ revenueTypes,
+ entitySaleDefaultValues: getEntityTypeDefaultValues(animalSale, 'animal'),
};
diff --git a/packages/webapp/src/util/getFirstNameWithLastInitial.js b/packages/webapp/src/util/getFirstNameWithLastInitial.js
new file mode 100644
index 0000000000..619aab9660
--- /dev/null
+++ b/packages/webapp/src/util/getFirstNameWithLastInitial.js
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+export const getFirstNameWithLastInitial = (user) => {
+ const lastInitial = user.last_name?.[0]?.toUpperCase();
+ return `${user.first_name}${lastInitial ? ` ${lastInitial}.` : ''}`;
+};
diff --git a/packages/webapp/src/util/index.js b/packages/webapp/src/util/index.js
index f980a79a4d..ae27f0eff1 100644
--- a/packages/webapp/src/util/index.js
+++ b/packages/webapp/src/util/index.js
@@ -177,8 +177,3 @@ export const sumObjectValues = (obj) => {
export const toTranslationKey = (text) => {
return text.toUpperCase().replaceAll(' ', '_');
};
-
-export const getFirstNameWithLastInitial = (user) => {
- const lastInitial = user.last_name?.[0]?.toUpperCase();
- return `${user.first_name}${lastInitial ? ` ${lastInitial}.` : ''}`;
-};