diff --git a/packages/webapp/public/locales/en/revenue.json b/packages/webapp/public/locales/en/revenue.json index 3b198a6fe6..586dc12455 100644 --- a/packages/webapp/public/locales/en/revenue.json +++ b/packages/webapp/public/locales/en/revenue.json @@ -1,4 +1,8 @@ { + "ANIMAL_SALE": { + "REVENUE_NAME": "Animal Sale", + "CUSTOM_DESCRIPTION": "Revenues generated from the sales of animal products." + }, "CROP_SALE": { "REVENUE_NAME": "Crop Sale", "CUSTOM_DESCRIPTION": "Revenues associated with the sale of crops harvested from this farm." diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json index ccf0ce29c9..6d5c85a135 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -1097,6 +1097,7 @@ "REVENUE_TYPES": "Search revenue type" }, "TRANSACTION": { + "ANIMALS": "Animals", "CROPS": "Crops", "DAILY_TOTAL": "DAILY TOTAL", "LABOUR_EXPENSE": "Labour expense", @@ -1880,11 +1881,15 @@ "ADD_SALE": { "ADD_CUSTOM_REVENUE_TYPE": "Add custom revenue type", "ADD_REVENUE": "Add revenue", + "ANIMAL_NOTES_PLACEHOLDER": "Animal sale description", + "CROP_NOTES_PLACEHOLDER": "Crop sale description", "CROP_REQUIRED": "Required", "CROP_VARIETY": "Crop variety", "FLOW": "revenue creation", "MANAGE_CUSTOM_REVENUE_TYPE": "Manage custom revenue types", + "NOTES_PLACEHOLDER": "Sale description", "SALE_VALUE_ERROR": "Sale value must be a positive number less than 999,999,999", + "SELECT_CROPS": "Select crops", "TABLE_HEADERS": { "TOTAL": "Total" }, diff --git a/packages/webapp/src/components/CardWithStatus/TaskCard/TaskCard.jsx b/packages/webapp/src/components/CardWithStatus/TaskCard/TaskCard.jsx index 052081f44c..8f533c9c89 100644 --- a/packages/webapp/src/components/CardWithStatus/TaskCard/TaskCard.jsx +++ b/packages/webapp/src/components/CardWithStatus/TaskCard/TaskCard.jsx @@ -33,7 +33,7 @@ export const taskStatusTranslateKey = { import { languageCodes } from '../../../hooks/useLanguageOptions'; import { getIntlDate } from '../../../util/date-migrate-TS'; -import { getFirstNameWithLastInitial } from '../../../util'; +import { getFirstNameWithLastInitial } from '../../../util/getFirstNameWithLastInitial'; import RevisionInfoText from '../../RevisionInfoText'; export const PureTaskCard = ({ diff --git a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/CropSaleTable.jsx b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/EntitySaleTable.jsx similarity index 71% rename from packages/webapp/src/components/Finances/Transaction/ExpandedContent/CropSaleTable.jsx rename to packages/webapp/src/components/Finances/Transaction/ExpandedContent/EntitySaleTable.jsx index 479a8b6711..806a4261fc 100644 --- a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/CropSaleTable.jsx +++ b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/EntitySaleTable.jsx @@ -20,10 +20,10 @@ import history from '../../../../history'; import styles from './styles.module.scss'; import { createRevenueDetailsUrl } from '../../../../util/siteMapConstants'; -const getColumns = (t, mobileView, totalAmount, quantityTotal, currencySymbol) => [ +const getColumns = (t, titleLabel, mobileView, totalAmount, currencySymbol) => [ { id: 'title', - label: t('FINANCES.TRANSACTION.CROPS'), + label: t(titleLabel), format: (d) => mobileView ? (
@@ -38,7 +38,7 @@ const getColumns = (t, mobileView, totalAmount, quantityTotal, currencySymbol) = columnProps: { style: { padding: `0 ${mobileView ? 8 : 12}px` }, }, - Footer: mobileView ? null : t('FINANCES.TRANSACTION.DAILY_TOTAL'), + Footer: mobileView ? null :
{t('common:TOTAL')}
, }, { id: mobileView ? null : 'quantity', @@ -48,7 +48,6 @@ const getColumns = (t, mobileView, totalAmount, quantityTotal, currencySymbol) = columnProps: { style: { width: '100px' }, }, - Footer: mobileView ? null :
{quantityTotal}
, }, { id: 'amount', @@ -62,20 +61,16 @@ const getColumns = (t, mobileView, totalAmount, quantityTotal, currencySymbol) = }, ]; -const FooterCell = ({ t, quantityTotal, totalAmount }) => ( +const FooterCell = ({ t, totalAmount }) => (
-
{t('FINANCES.TRANSACTION.DAILY_TOTAL')}
-
{quantityTotal}
+
{t('common:TOTAL')}
{totalAmount}
); -export default function CropSaleTable({ data, currencySymbol, mobileView }) { +export default function EntitySaleTable({ data, currencySymbol, mobileView, titleLabel }) { const { t } = useTranslation(); const { items, amount, relatedId } = data; - const quantityUnit = items?.[0]?.quantityUnit; - const quantityTotal = items.reduce((total, { quantity }) => total + quantity, 0); - const quantityWithUnit = `${quantityTotal} ${quantityUnit}`; const totalAmount = `${currencySymbol}${amount.toFixed(2)}`; if (!items?.length) { @@ -85,15 +80,11 @@ export default function CropSaleTable({ data, currencySymbol, mobileView }) { return ( - : null - } + FooterCell={mobileView ? () => : null} onClickMore={() => history.push(createRevenueDetailsUrl(relatedId))} /> ); diff --git a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/index.jsx b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/index.jsx index 50d9ade075..dd4d5c7876 100644 --- a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/index.jsx +++ b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/index.jsx @@ -24,9 +24,9 @@ import { LABOUR_URL, } from '../../../../util/siteMapConstants'; import TextButton from '../../../Form/Button/TextButton'; -import CropSaleTable from './CropSaleTable'; import GeneralTransactionTable from './GeneralTransactionTable'; import LabourTable from './LabourTable'; +import EntitySaleTable from './EntitySaleTable'; import styles from './styles.module.scss'; import navStyles from '@navStyles'; @@ -34,7 +34,10 @@ const components = { EXPENSE: (props) => , REVENUE: (props) => , LABOUR_EXPENSE: (props) => , - CROP_REVENUE: (props) => , + CROP_REVENUE: (props) => , + ANIMAL_REVENUE: (props) => ( + + ), }; const getDetailPageLink = ({ transactionType, relatedId }) => { @@ -43,6 +46,7 @@ const getDetailPageLink = ({ transactionType, relatedId }) => { EXPENSE: createExpenseDetailsUrl(relatedId), REVENUE: createRevenueDetailsUrl(relatedId), CROP_REVENUE: createRevenueDetailsUrl(relatedId), + ANIMAL_REVENUE: createRevenueDetailsUrl(relatedId), }[transactionType]; }; diff --git a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/styles.module.scss b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/styles.module.scss index 37c1fee399..b4e0393dc2 100644 --- a/packages/webapp/src/components/Finances/Transaction/ExpandedContent/styles.module.scss +++ b/packages/webapp/src/components/Finances/Transaction/ExpandedContent/styles.module.scss @@ -69,6 +69,10 @@ button.toDetail { font-weight: bold; } +.uppercase { + text-transform: uppercase; +} + // Crop sales .mobileCrops { height: 100%; diff --git a/packages/webapp/src/components/Forms/GeneralRevenue/styles.module.scss b/packages/webapp/src/components/Forms/GeneralRevenue/styles.module.scss deleted file mode 100644 index aa62d02c9d..0000000000 --- a/packages/webapp/src/components/Forms/GeneralRevenue/styles.module.scss +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2023 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 . - */ - -@use '@assets/mixin' as *; - -.defaultFormDropDown { - width: 100%; - padding-bottom: 15px; - font-size: 1.8rem; - label { - font-weight: 300; - line-height: 20px; - font-size: 1.8rem; - width: 75px; - } - input { - height: 30px; - line-height: 1.1; - } -} - -.banner { - display: flex; - hr { - background-color: black; - height: 2px; - } - - p:first-child { - flex-basis: 30%; - } - - p:nth-child(2) { - flex-basis: 35%; - } - - p:last-child { - flex-basis: 35%; - } - - p { - font-size: 14px; - line-height: 24px; - color: var(--fontColor); - font-style: normal; - font-weight: normal; - @include fontFamily(); - } -} - -.thinHr { - background-color: lightgrey; - height: 1px; -} -.thinHr2 { - background-color: lightgrey; - height: 1px; - margin-bottom: 18px; -} - -.dateContainer { - label { - font-style: normal; - font-weight: normal; - color: var(--labels); - font-size: 14px; - line-height: 16px; - @include fontFamily(); - margin-bottom: 4px; - } - padding-top: 10px; - width: 100%; - margin: 0 0 18px; -} - -.date { - input { - font-size: 1.8rem; - border: 1px solid #d3d3d3; - width: 125px; - border-radius: 5px; - background-color: hsl(0, 0%, 98%); - } - display: flex; - flex-direction: row; - align-items: center; - ::placeholder { - color: #00756a; - } -} - -.saleContainer { - width: 100%; -} - -.sale { - display: flex; -} -.saleItemContainer { - display: flex; - margin-top: 32px; - margin-bottom: 32px; -} - -.saleItemInputGroup { - min-width: 200px; - flex-grow: 1; - margin-left: 24px; -} - -.saleItemInputGroup > * { - margin-bottom: 40px; -} - -.selectionErrorZone { - height: 32px; -} diff --git a/packages/webapp/src/components/Forms/RevenueForm/AnimalSaleItem.tsx b/packages/webapp/src/components/Forms/RevenueForm/AnimalSaleItem.tsx new file mode 100644 index 0000000000..7b89c4cd8c --- /dev/null +++ b/packages/webapp/src/components/Forms/RevenueForm/AnimalSaleItem.tsx @@ -0,0 +1,56 @@ +/* + * 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 { Semibold } from '../../Typography'; +import SaleLineItem from './SaleLineItem'; +import styles from './styles.module.scss'; + +interface AnimalSaleItemProps { + animalName: string; + entityId: string; + system: string; + currency: string; + fieldPrefix: string; + entityIdFieldKey: string; + disabledInput: boolean; +} + +function AnimalSaleItem({ + animalName, + entityId, + system, + currency, + fieldPrefix, + entityIdFieldKey, + disabledInput, +}: AnimalSaleItemProps) { + return ( +
+
+ {animalName} + +
+
+ ); +} + +export default AnimalSaleItem; diff --git a/packages/webapp/src/components/Forms/GeneralRevenue/CropSaleItem.jsx b/packages/webapp/src/components/Forms/RevenueForm/CropSaleItem.jsx similarity index 100% rename from packages/webapp/src/components/Forms/GeneralRevenue/CropSaleItem.jsx rename to packages/webapp/src/components/Forms/RevenueForm/CropSaleItem.jsx diff --git a/packages/webapp/src/containers/Finances/EntitySaleInputs.tsx b/packages/webapp/src/components/Forms/RevenueForm/EntitySalePicker.tsx similarity index 66% rename from packages/webapp/src/containers/Finances/EntitySaleInputs.tsx rename to packages/webapp/src/components/Forms/RevenueForm/EntitySalePicker.tsx index f910137ecc..d234dc6ecb 100644 --- a/packages/webapp/src/containers/Finances/EntitySaleInputs.tsx +++ b/packages/webapp/src/components/Forms/RevenueForm/EntitySalePicker.tsx @@ -17,49 +17,41 @@ import { ReactNode, useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { MultiValue } from 'react-select'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; -import { measurementSelector } from '../userFarmSlice'; -import { - QUANTITY, - QUANTITY_UNIT, - SALE_VALUE, -} from '../../components/Forms/GeneralRevenue/constants'; -import { CheckboxMultiSelect } from '../../components/Form/ReactSelect/CheckboxMultiSelect'; -import type { SelectOption } from '../../components/Form/ReactSelect/CheckboxMultiSelect'; -import { Error } from '../../components/Typography'; -import styles from '../../components/Forms/GeneralRevenue/styles.module.scss'; -import { useCurrencySymbol } from '../hooks/useCurrencySymbol'; +import { QUANTITY, QUANTITY_UNIT, SALE_VALUE } from './constants'; +import { CheckboxMultiSelect } from '../../Form/ReactSelect/CheckboxMultiSelect'; +import type { SelectOption } from '../../Form/ReactSelect/CheckboxMultiSelect'; +import { Error } from '../../Typography'; +import InputBaseLabel from '../../Form/InputBase/InputBaseLabel'; +import styles from './styles.module.scss'; export interface EntitySaleItemProps { option: SelectOption; - system: string; - currency: string; fieldPrefix: string; disabledInput: boolean; } -interface EntitySaleInputsProps { +interface EntitySalePickerProps { disabledInput: boolean; options: SelectOption[]; savedSalesById: Record | null | undefined; fieldPrefix: string; entityIdFieldKey: string; + label: string; placeholder?: string; children: (props: EntitySaleItemProps) => ReactNode; } -export default function EntitySaleInputs({ +export default function EntitySalePicker({ disabledInput, options, savedSalesById, fieldPrefix, entityIdFieldKey, + label, placeholder, children, -}: EntitySaleInputsProps): ReactNode { +}: EntitySalePickerProps): ReactNode { const { t } = useTranslation(); - const system = useSelector(measurementSelector); - const currency = useCurrencySymbol(); const { register, unregister, getValues, setValue } = useFormContext(); const [selectedOptions, setSelectedOptions] = useState(() => @@ -94,27 +86,27 @@ export default function EntitySaleInputs({ }; return ( - <> - -
+
+
+ + {!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}.` : ''}`; -};