diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml new file mode 100644 index 0000000000..a8d3a10b2a --- /dev/null +++ b/.github/workflows/crowdin-download.yml @@ -0,0 +1,63 @@ +name: Crowdin Download Action + +on: + workflow_dispatch: + +jobs: + download-translations: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 # Should be 1 to avoid parallel builds + matrix: + locales: [ + # Frontend core translations + { + name: webapp_locales, + source: packages/webapp/public/locales/en/*.json, + translation: packages/webapp/public/locales/%two_letters_code%/%original_file_name%, + }, + # Consent Forms + { + name: webapp_consent, + source: packages/webapp/src/containers/Consent/locales/en/*.md, + translation: packages/webapp/src/containers/Consent/locales/%two_letters_code%/%original_file_name%, + }, + # Backend tranlsations - skipping pdf (crop.json is copied jobs scheduler init during build) + { + name: api_job_locales, + source: packages/api/src/jobs/locales/en/*.json, + translation: packages/api/src/jobs/locales/%two_letters_code%/%original_file_name%, + }, + # email templates + { + name: api_email_templates, + source: packages/api/src/templates/locales/en.json, + translation: packages/api/src/templates/locales/%two_letters_code%.%file_extension%, + }, + ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Synchronize with Crowdin + uses: crowdin/github-action@v2 + with: + upload_sources: false + upload_translations: false + download_translations: true + skip_untranslated_strings: true + export_only_approved: true + source: ${{ matrix.locales.source }} # Sources pattern + translation: ${{ matrix.locales.translation }} # Translations pattern + localization_branch_name: l10n_crowdin_translations_${{ env.BRANCH_NAME }}_${{ matrix.locales.name }} + create_pull_request: true + pull_request_title: "New Crowdin translations" + pull_request_body: "New Crowdin pull request with translations" + pull_request_base_branch_name: ${{ env.BRANCH_NAME }} + crowdin_branch_name: ${{ env.BRANCH_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.CROWDIN_TEMP_PERSONAL_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml new file mode 100644 index 0000000000..4a67a49b56 --- /dev/null +++ b/.github/workflows/crowdin-upload.yml @@ -0,0 +1,62 @@ +name: Crowdin Upload Action + +on: + workflow_dispatch: + push: + branches: + - integration + paths: + - "**/locales/**.md" + - "**/locales/**.json" + +jobs: + synchronize-translations: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 1 # Should be 1 to avoid parallel builds + matrix: + locales: [ + # Frontend core translations + { + source: packages/webapp/public/locales/en/*.json, + translation: packages/webapp/public/locales/%two_letters_code%/%original_file_name%, + }, + # Consent Forms + { + source: packages/webapp/src/containers/Consent/locales/en/*.md, + translation: packages/webapp/src/containers/Consent/locales/%two_letters_code%/%original_file_name%, + }, + # Backend tranlsations - skipping pdf (crop.json is copied jobs scheduler init during build) + { + source: packages/api/src/jobs/locales/en/*.json, + translation: packages/api/src/jobs/locales/%two_letters_code%/%original_file_name%, + }, + # email templates + { + source: packages/api/src/templates/locales/en.json, + translation: packages/api/src/templates/locales/%two_letters_code%.%file_extension%, + }, + ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Crowdin sync + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + upload_sources_args: --preserve-hierarchy + upload_translations_args: --preserve-hierarchy + download_translations: false + auto_approve_imported: false + import_eq_suggestions: true + crowdin_branch_name: ${{ env.BRANCH_NAME }} + source: ${{ matrix.locales.source }} # Sources pattern + translation: ${{ matrix.locales.translation }} # Translations pattern + env: + GITHUB_TOKEN: ${{ secrets.CROWDIN_TEMP_PERSONAL_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} diff --git a/packages/api/db/migration/20240425225219_add_npk_and_update_permissions_on_product.js b/packages/api/db/migration/20240425225219_add_npk_and_update_permissions_on_product.js new file mode 100644 index 0000000000..82d14c3741 --- /dev/null +++ b/packages/api/db/migration/20240425225219_add_npk_and_update_permissions_on_product.js @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 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 up = async function (knex) { + await knex.schema.alterTable('product', (table) => { + table.decimal('n'); + table.decimal('p'); + table.decimal('k'); + table.enu('npk_unit', ['ratio', 'percent']); + + table.check( + '(COALESCE(n, p, k) IS NULL AND npk_unit IS NULL) OR (COALESCE(n, p, k) IS NOT NULL AND npk_unit IS NOT NULL)', + [], + 'npk_unit_check', + ); + table.check( + "npk_unit != 'percent' OR (npk_unit = 'percent' AND (n + p + k) <= 100)", + [], + 'npk_percent_check', + ); + }); + + // Add missing permissions for product actions + await knex('permissions').insert([ + { + permission_id: 169, + name: 'edit:product', + description: 'edit products', + }, + { + permission_id: 170, + name: 'delete:product', + description: 'delete products', + }, + ]); + + await knex('rolePermissions').insert([ + { role_id: 3, permission_id: 129 }, // add worker permissions to add:product + { role_id: 1, permission_id: 169 }, + { role_id: 2, permission_id: 169 }, + { role_id: 3, permission_id: 169 }, + { role_id: 5, permission_id: 169 }, + { role_id: 1, permission_id: 170 }, + { role_id: 2, permission_id: 170 }, + { role_id: 5, permission_id: 170 }, + ]); +}; + +export const down = async function (knex) { + await knex('rolePermissions') + .where({ + role_id: 3, + permission_id: 129, + }) + .del(); + + const permissions = [169, 170]; + + await knex('rolePermissions').whereIn('permission_id', permissions).del(); + await knex('permissions').whereIn('permission_id', permissions).del(); + + await knex.schema.alterTable('product', (table) => { + table.dropChecks(['npk_unit_check', 'npk_percent_check']); + + table.dropColumn('n'); + table.dropColumn('p'); + table.dropColumn('k'); + table.dropColumn('npk_unit'); + }); +}; diff --git a/packages/api/db/migration/20240504185218_create_soil_amendement_task_products_table.js b/packages/api/db/migration/20240504185218_create_soil_amendement_task_products_table.js new file mode 100644 index 0000000000..c134632c0a --- /dev/null +++ b/packages/api/db/migration/20240504185218_create_soil_amendement_task_products_table.js @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2024 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 . + */ + +const soilAmendmentFertiliserTypeKeys = ['DRY', 'LIQUID']; +const elementalUnits = ['percent', 'ratio', 'ppm', 'mg/kg']; +const molecularCompoundsUnits = ['ppm', 'mg/kg']; +const elements = ['n', 'p', 'k', 'calcium', 'magnesium', 'sulfur', 'copper', 'manganese', 'boron']; +const compounds = ['ammonium', 'nitrate']; +const amendmentProductPurposeKeys = [ + 'STRUCTURE', + 'MOISTURE_RETENTION', + 'NUTRIENT_AVAILABILITY', + 'PH', + 'OTHER', +]; +const amendmentMethodKeys = [ + 'BROADCAST', + 'BANDED', + 'FURROW_HOLE', + 'SIDE_DRESS', + 'FERTIGATION', + 'FOLIAR', + 'OTHER', +]; +const furrowHoleDepthUnits = ['cm', 'in']; +const metricWeightUnits = ['g', 'kg', 'mt']; +const imperialWeightUnits = ['oz', 'lb', 't']; +const weightUnits = [...metricWeightUnits, ...imperialWeightUnits]; +const applicationRateWeightUnits = [ + 'g/m2', + 'lb/ft2', + 'kg/m2', + 't/ft2', + 'mt/m2', + 'oz/ft2', + 'g/ha', + 'lb/ac', + 'kg/ha', + 't/ac', + 'mt/ha', + 'oz/ac', +]; +const metricVolumeUnits = ['ml', 'l']; +const imperialVolumeUnits = ['fl-oz', 'gal']; +const volumeUnits = [...metricVolumeUnits, ...imperialVolumeUnits]; +const applicationRateVolumeUnits = [ + 'l/m2', + 'gal/ft2', + 'ml/m2', + 'fl-oz/ft2', + 'l/ha', + 'gal/ac', + 'ml/ha', + 'fl-oz/ac', +]; + +export const up = async function (knex) { + // Create fertiliser types table + await knex.schema.createTable('soil_amendment_fertiliser_type', (table) => { + table.increments('id').primary(); + table.string('key').notNullable(); + }); + // Add fertiliser types + for (const key of soilAmendmentFertiliserTypeKeys) { + await knex('soil_amendment_fertiliser_type').insert({ + key, + }); + } + + await knex.schema.createTable('soil_amendment_product', (table) => { + table.integer('product_id').references('product_id').inTable('product').primary(); + table + .integer('soil_amendment_fertiliser_type_id') + .references('id') + .inTable('soil_amendment_fertiliser_type') + .nullable(); + table.decimal('n', 36, 12); + table.decimal('p', 36, 12); + table.decimal('k', 36, 12); + table.decimal('calcium', 36, 12); + table.decimal('magnesium', 36, 12); + table.decimal('sulfur', 36, 12); + table.decimal('copper', 36, 12); + table.decimal('manganese', 36, 12); + table.decimal('boron', 36, 12); + table.enu('elemental_unit', elementalUnits); + table.decimal('ammonium', 36, 12); + table.decimal('nitrate', 36, 12); + table.enu('molecular_compounds_unit', molecularCompoundsUnits); + // Dry matter content is currently 100 - moisture content + table.decimal('moisture_content_percent', 36, 12); + table.check( + `(COALESCE(${elements.join( + ', ', + )}) IS NULL AND elemental_unit IS NULL) OR (COALESCE(${elements.join( + ', ', + )}) IS NOT NULL AND elemental_unit IS NOT NULL)`, + [], + 'elemental_unit_check', + ); + table.check( + `elemental_unit != 'percent' OR (elemental_unit = 'percent' AND (${elements + .map((element) => `COALESCE(${element}, 0)`) + .join(' + ')}) <= 100)`, + [], + 'elemental_percent_check', + ); + table.check( + `(COALESCE(${compounds.join( + ', ', + )}) IS NULL AND molecular_compounds_unit IS NULL) OR (COALESCE(${compounds.join( + ', ', + )}) IS NOT NULL AND molecular_compounds_unit IS NOT NULL)`, + [], + 'molecular_compounds_unit_check', + ); + }); + + // TODO: Migrate fertilizer table into here? + // Insert blank record into soil amendment product + await knex.raw(` + INSERT INTO soil_amendment_product (product_id) + SELECT product_id FROM product + WHERE product.type = 'soil_amendment_task' + `); + + // Create amendment purpose table + await knex.schema.createTable('soil_amendment_purpose', (table) => { + table.increments('id').primary(); + table.string('key').notNullable(); + }); + + // Add purpose types + for (const key of amendmentProductPurposeKeys) { + await knex('soil_amendment_purpose').insert({ + key, + }); + } + + // Create amendment task method table + await knex.schema.createTable('soil_amendment_method', (table) => { + table.increments('id').primary(); + table.string('key').notNullable(); + }); + + // Add method types + for (const key of amendmentMethodKeys) { + await knex('soil_amendment_method').insert({ + key, + }); + } + + // Create new product table + await knex.schema.createTable('soil_amendment_task_products', (table) => { + table.increments('id').primary(); + table.integer('task_id').references('task_id').inTable('task').notNullable(); + // TODO: LF-4246 backfill data if nulls exist on prod and constrain this to notNullable() + table.integer('product_id').references('product_id').inTable('product'); + table.decimal('weight', 36, 12); + table.enu('weight_unit', weightUnits); + table.decimal('volume', 36, 12); + table.enu('volume_unit', volumeUnits); + table.enu('application_rate_weight_unit', applicationRateWeightUnits); + table.enu('application_rate_volume_unit', applicationRateVolumeUnits); + // TODO: LF-4246 backfill data for percent_of_location_amended then make notNullable and defaulted to 100 + table.decimal('percent_of_location_amended', 36, 12); + // TODO: LF-4246 backfill data for total_area_amended in m2 then make notNullable + table.decimal('total_area_amended', 36, 12); + table.boolean('deleted').notNullable().defaultTo(false); + table + .string('created_by_user_id') + .notNullable() + .references('user_id') + .inTable('users') + .defaultTo(1); + table + .string('updated_by_user_id') + .notNullable() + .references('user_id') + .inTable('users') + .defaultTo(1); + table.dateTime('created_at').notNullable().defaultTo(new Date('2000/1/1').toISOString()); + table.dateTime('updated_at').notNullable().defaultTo(new Date('2000/1/1').toISOString()); + // TODO: LF-4246 null product quantities exist on prod remove last OR constraint condition in the future + table.check( + '(?? IS NULL AND ?? IS NOT NULL) OR (?? IS NULL AND ?? IS NOT NULL) OR (?? IS NULL AND ?? IS NULL)', + ['weight', 'volume', 'volume', 'weight', 'weight', 'volume'], + 'volume_or_weight_check', + ); + table.check( + '(?? IS NULL AND ?? IS NULL AND ?? IS NULL) OR (?? IS NOT NULL AND ?? IS NOT NULL AND ?? IS NOT NULL)', + [ + 'weight', + 'weight_unit', + 'application_rate_weight_unit', + 'weight', + 'weight_unit', + 'application_rate_weight_unit', + ], + 'weight_unit_check', + ); + table.check( + '(?? IS NULL AND ?? IS NULL AND ?? IS NULL) OR (?? IS NOT NULL AND ?? IS NOT NULL AND ?? IS NOT NULL)', + [ + 'volume', + 'volume_unit', + 'application_rate_volume_unit', + 'volume', + 'volume_unit', + 'application_rate_volume_unit', + ], + 'volume_unit_check', + ); + }); + + const soilAmendmentPurposes = await knex.select().table('soil_amendment_purpose'); + + // Create amendment purpose table + await knex.schema.createTable('soil_amendment_task_products_purpose_relationship', (table) => { + table + .integer('task_products_id') + .references('id') + .inTable('soil_amendment_task_products') + .notNullable(); + table.integer('purpose_id').references('id').inTable('soil_amendment_purpose').notNullable(); + table.primary(['task_products_id', 'purpose_id']); + table.string('other_purpose'); + }); + + // Migrate existing weight data to the new table (reversibly) + // This case should never happen - but if it does lets not delete data + await knex.raw(` + INSERT INTO soil_amendment_task_products (task_id, product_id, weight, weight_unit, application_rate_weight_unit) + SELECT task_id, product_id, product_quantity, (CASE WHEN product_quantity IS NOT NULL THEN 'kg'::text ELSE NULL END) , (CASE WHEN product_quantity IS NOT NULL THEN 'kg/ha'::text ELSE NULL END) FROM soil_amendment_task + WHERE product_quantity_unit IS NULL + `); + + await knex.raw(` + INSERT INTO soil_amendment_task_products (task_id, product_id, weight, weight_unit, application_rate_weight_unit) + SELECT task_id, product_id, product_quantity, (CASE WHEN product_quantity IS NOT NULL THEN product_quantity_unit ELSE NULL END) , (CASE WHEN product_quantity IS NOT NULL THEN 'kg/ha'::text ELSE NULL END) FROM soil_amendment_task + WHERE product_quantity_unit IN ('${metricWeightUnits.join("', '")}') + `); + + await knex.raw(` + INSERT INTO soil_amendment_task_products (task_id, product_id, weight, weight_unit, application_rate_weight_unit) + SELECT task_id, product_id, product_quantity,(CASE WHEN product_quantity IS NOT NULL THEN product_quantity_unit ELSE NULL END), (CASE WHEN product_quantity IS NOT NULL THEN 'lb/ac'::text ELSE NULL END) FROM soil_amendment_task + WHERE product_quantity_unit IN ('${imperialWeightUnits.join("', '")}') + `); + + // Migrate existing volume data to the new table (reversibly) + await knex.raw(` + INSERT INTO soil_amendment_task_products (task_id, product_id, volume, volume_unit, application_rate_volume_unit) + SELECT task_id, product_id, product_quantity,(CASE WHEN product_quantity IS NOT NULL THEN product_quantity_unit ELSE NULL END), (CASE WHEN product_quantity IS NOT NULL THEN 'l/ha'::text ELSE NULL END) FROM soil_amendment_task + WHERE product_quantity_unit IN ('${metricVolumeUnits.join("', '")}') + `); + + await knex.raw(` + INSERT INTO soil_amendment_task_products (task_id, product_id, volume, volume_unit, application_rate_volume_unit) + SELECT task_id, product_id, product_quantity, (CASE WHEN product_quantity IS NOT NULL THEN product_quantity_unit ELSE NULL END), (CASE WHEN product_quantity IS NOT NULL THEN 'gal/ac'::text ELSE NULL END) FROM soil_amendment_task + WHERE product_quantity_unit IN ('${imperialVolumeUnits.join("', '")}') + `); + + // Get Product Ids, and Purposes + const soilAmendmentTaskPurposes = await knex + .select('task_id', 'purpose', 'other_purpose') + .table('soil_amendment_task'); + const soilAmendmentTaskProducts = await knex + .select('id', 'task_id') + .table('soil_amendment_task_products'); + const soilAmendmentTaskProductsPurposes = soilAmendmentTaskPurposes.map((task) => { + const soilAmendmentTaskProduct = soilAmendmentTaskProducts.find( + (pr) => pr.task_id === task.task_id, + ); + const soilAmendmentPurpose = soilAmendmentPurposes.find( + (pu) => pu.key === task.purpose?.toUpperCase(), + ); + return { + task_products_id: soilAmendmentTaskProduct?.id || null, + purpose_id: soilAmendmentPurpose?.id || null, + other_purpose: task.other_purpose || null, + }; + }); + + // Insert into relationship table + for (const taskProductPurpose of soilAmendmentTaskProductsPurposes) { + if (taskProductPurpose.purpose_id && taskProductPurpose.task_products_id) { + await knex('soil_amendment_task_products_purpose_relationship').insert(taskProductPurpose); + } + // LF-4246 - no purpose_id or task_product_id + } + + const soilAmendmentMethods = await knex.select().table('soil_amendment_method'); + const otherSoilAmendmentMethod = soilAmendmentMethods.find((method) => method.key === 'OTHER'); + + // Alter soil amendmendment task + await knex.schema.alterTable('soil_amendment_task', (table) => { + table.dropColumn('product_id'); + table.dropColumn('product_quantity'); + table.dropColumn('product_quantity_unit'); + table.dropColumn('other_purpose'); + table.dropColumn('purpose'); + table.integer('method_id').references('id').inTable('soil_amendment_method'); + table.decimal('furrow_hole_depth', 36, 12); + table.enu('furrow_hole_depth_unit', furrowHoleDepthUnits); + table.string('other_application_method'); + table.check( + `(other_application_method IS NOT NULL AND method_id = ${otherSoilAmendmentMethod.id}) OR (other_application_method IS NULL)`, + [], + 'other_application_method_id_check', + ); + }); + + // Alter cleaning task for volume and weight + await knex.schema.alterTable('cleaning_task', (table) => { + table.decimal('weight', 36, 12); + table.enu('weight_unit', weightUnits); + table.decimal('volume', 36, 12); + table.enu('volume_unit', volumeUnits); + }); + + // Alter pest control task for volume and weight + await knex.schema.alterTable('pest_control_task', (table) => { + table.decimal('weight', 36, 12); + table.enu('weight_unit', weightUnits); + table.decimal('volume', 36, 12); + table.enu('volume_unit', volumeUnits); + }); + + // Migrate cleaning task existing volume and weight data (reversibly) + await knex.raw(` + UPDATE cleaning_task + SET (volume, volume_unit) = (product_quantity, product_quantity_unit) + WHERE product_quantity IS NOT NULL + AND product_quantity_unit IN ('${volumeUnits.join("', '")}') + `); + await knex.raw(` + UPDATE cleaning_task + SET (weight, weight_unit) = (product_quantity, product_quantity_unit) + WHERE product_quantity IS NOT NULL + AND product_quantity_unit IN ('${weightUnits.join("', '")}') + `); + + // Product is not required for cleaning task + // https://github.com/knex/knex/issues/5966 + await knex.schema.alterTable('cleaning_task', (table) => { + table.check( + '(weight IS NULL AND volume IS NOT NULL) OR (volume IS NULL AND weight IS NOT NULL) OR (weight IS NULL AND volume IS NULL)', + [], + 'volume_or_weight_check', + ); + table.check( + '(weight IS NULL AND weight_unit IS NULL) OR (weight IS NOT NULL AND weight_unit IS NOT NULL)', + [], + 'weight_unit_check', + ); + table.check( + '(volume IS NULL AND volume_unit IS NULL) OR (volume IS NOT NULL AND volume_unit IS NOT NULL)', + [], + 'volume_unit_check', + ); + }); + + // Migrate pest control task existing volume and weight data (reversibly) + await knex.raw(` + UPDATE pest_control_task + SET (volume, volume_unit) = (product_quantity, product_quantity_unit) + WHERE product_quantity IS NOT NULL + AND product_quantity_unit IN ('${volumeUnits.join("', '")}') + `); + await knex.raw(` + UPDATE pest_control_task + SET (weight, weight_unit) = (product_quantity, product_quantity_unit) + WHERE product_quantity IS NOT NULL + AND product_quantity_unit IN ('${weightUnits.join("', '")}') + `); + + // Product not required for pest control task + await knex.schema.alterTable('pest_control_task', (table) => { + table.check( + '(weight IS NULL AND volume IS NOT NULL) OR (volume IS NULL AND weight IS NOT NULL) OR (weight IS NULL AND volume IS NULL)', + [], + 'volume_or_weight_check', + ); + table.check( + '(weight IS NULL AND weight_unit IS NULL) OR (weight IS NOT NULL AND weight_unit IS NOT NULL)', + [], + 'weight_unit_check', + ); + table.check( + '(volume IS NULL AND volume_unit IS NULL) OR (volume IS NOT NULL AND volume_unit IS NOT NULL)', + [], + 'volume_unit_check', + ); + }); + + // Alter cleaning task for volume and weight + await knex.schema.alterTable('cleaning_task', (table) => { + table.dropColumn('product_quantity'); + table.dropColumn('product_quantity_unit'); + }); + + // Alter cleaning task for volume and weight + await knex.schema.alterTable('pest_control_task', (table) => { + table.dropColumn('product_quantity'); + table.dropColumn('product_quantity_unit'); + }); + + // Alter product to add product state type id + // Removing npk to add new table instead no attempt to keep values as it is currently unused + await knex.schema.alterTable('product', (table) => { + table.dropChecks(['npk_unit_check', 'npk_percent_check']); + table.dropColumn('n'); + table.dropColumn('p'); + table.dropColumn('k'); + table.dropColumn('npk_unit'); + }); + + // Add permissions + // Use task or product permissions as needed +}; + +export const down = async function (knex) { + // No prod data unreleased feature - destroys data + await knex.schema.alterTable('product', (table) => { + table.decimal('n'); + table.decimal('p'); + table.decimal('k'); + table.enu('npk_unit', ['ratio', 'percent']); + table.check( + '(COALESCE(n, p, k) IS NULL AND npk_unit IS NULL) OR (COALESCE(n, p, k) IS NOT NULL AND npk_unit IS NOT NULL)', + [], + 'npk_unit_check', + ); + table.check( + "npk_unit != 'percent' OR (npk_unit = 'percent' AND (n + p + k) <= 100)", + [], + 'npk_percent_check', + ); + }); + + // Reverse product data migration + await knex.schema.alterTable('soil_amendment_task', (table) => { + table.dropChecks(['other_application_method_id_check']); + table.decimal('product_quantity', 36, 12); + table.enu('product_quantity_unit', [...weightUnits, ...volumeUnits]).defaultTo('kg'); + table.integer('product_id').references('product_id').inTable('product'); + table.string('other_purpose'); + table.enu( + 'purpose', + amendmentProductPurposeKeys.map((k) => k.toLowerCase()), + ); + table.dropColumn('method_id'); + table.dropColumn('other_application_method'); + table.dropColumn('furrow_hole_depth'); + table.dropColumn('furrow_hole_depth_unit'); + }); + + await knex.schema.alterTable('cleaning_task', (table) => { + table.decimal('product_quantity', 36, 12); + table.enu('product_quantity_unit', volumeUnits).defaultTo('l'); + }); + + await knex.schema.alterTable('pest_control_task', (table) => { + table.decimal('product_quantity', 36, 12); + table.text('product_quantity_unit', [...weightUnits, ...volumeUnits]).defaultTo('l'); + }); + + // only saves the first product, destroys application rates + const soilAmendmentTasks = await knex.select('*').table('soil_amendment_task'); + for (const task of soilAmendmentTasks) { + const firstTaskProduct = await knex + .select('*') + .table('soil_amendment_task_products') + .where('task_id', task.task_id) + .orderBy('id', 'asc') + .first(); + const firstTaskProductPurposeRelationship = await knex + .select('*') + .table('soil_amendment_task_products_purpose_relationship') + .where('task_products_id', firstTaskProduct.id) + .first(); + const firstTaskProductPurpose = firstTaskProductPurposeRelationship + ? await knex + .select('key') + .table('soil_amendment_purpose') + .where('id', firstTaskProductPurposeRelationship.purpose_id) + : null; + if (firstTaskProduct.weight) { + await knex('soil_amendment_task') + .where('task_id', task.task_id) + .update({ + product_id: firstTaskProduct.product_id, + product_quantity: firstTaskProduct.weight, + product_quantity_unit: firstTaskProduct.weight_unit, + other_purpose: firstTaskProductPurposeRelationship?.other_purpose || null, + purpose: firstTaskProductPurpose + ? String(firstTaskProductPurpose[0].key).toLowerCase() + : null, + }); + } else if (firstTaskProduct.volume) { + await knex('soil_amendment_task') + .where('task_id', task.task_id) + .update({ + product_id: firstTaskProduct.product_id, + product_quantity: firstTaskProduct.volume, + product_quantity_unit: firstTaskProduct.volume_unit, + other_purpose: firstTaskProductPurposeRelationship?.other_purpose || null, + purpose: firstTaskProductPurpose + ? String(firstTaskProductPurpose[0].key).toLowerCase() + : null, + }); + } else { + await knex('soil_amendment_task') + .where('task_id', task.task_id) + .update({ + product_id: firstTaskProduct.product_id || null, + other_purpose: firstTaskProductPurposeRelationship?.other_purpose || null, + purpose: firstTaskProductPurpose + ? String(firstTaskProductPurpose[0].key).toLowerCase() + : null, + }); + } + } + + // Reverses cleaning task separation + const cleaningTasks = await knex.select('*').table('cleaning_task'); + for (const task of cleaningTasks) { + if (task.weight) { + await knex('cleaning_task').where('task_id', task.task_id).update({ + product_quantity: task.weight, + product_quantity_unit: task.weight_unit, + }); + } else if (task.volume) { + await knex('cleaning_task').where('task_id', task.task_id).update({ + product_quantity: task.volume, + product_quantity_unit: task.volume_unit, + }); + } + } + + const pestControlTasks = await knex.select('*').table('pest_control_task'); + for (const task of pestControlTasks) { + if (task.weight) { + await knex('pest_control_task').where('task_id', task.task_id).update({ + product_quantity: task.weight, + product_quantity_unit: task.weight_unit, + }); + } else if (task.volume) { + await knex('pest_control_task').where('task_id', task.task_id).update({ + product_quantity: task.volume, + product_quantity_unit: task.volume_unit, + }); + } + } + + await knex.schema.alterTable('cleaning_task', (table) => { + table.dropChecks(['volume_or_weight_check', 'weight_unit_check', 'volume_unit_check']); + table.dropColumn('weight'); + table.dropColumn('weight_unit'); + table.dropColumn('volume'); + table.dropColumn('volume_unit'); + }); + + await knex.schema.alterTable('pest_control_task', (table) => { + table.dropChecks(['volume_or_weight_check', 'weight_unit_check', 'volume_unit_check']); + table.dropColumn('weight'); + table.dropColumn('weight_unit'); + table.dropColumn('volume'); + table.dropColumn('volume_unit'); + }); + + await knex.schema.dropTable('soil_amendment_task_products_purpose_relationship'); + await knex.schema.dropTable('soil_amendment_task_products'); + await knex.schema.dropTable('soil_amendment_purpose'); + await knex.schema.dropTable('soil_amendment_method'); + await knex.schema.dropTable('soil_amendment_product'); + await knex.schema.dropTable('soil_amendment_fertiliser_type'); + + //Remove permissions + // Use task or product permissions as needed +}; diff --git a/packages/api/db/migration/20240617183222_add_missing_checks_soil_amendment.js b/packages/api/db/migration/20240617183222_add_missing_checks_soil_amendment.js new file mode 100644 index 0000000000..5fbde965c3 --- /dev/null +++ b/packages/api/db/migration/20240617183222_add_missing_checks_soil_amendment.js @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 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 up = async function (knex) { + await knex.schema.alterTable('soil_amendment_product', (table) => { + table.check('moisture_content_percent <= 100', [], 'moisture_percent_check'); + }); + + const soilAmendmentPurposes = await knex.select().table('soil_amendment_purpose'); + const otherPurpose = soilAmendmentPurposes.find((pu) => pu.key === 'OTHER'); + + await knex('soil_amendment_task_products_purpose_relationship') + .whereNot({ purpose_id: otherPurpose.id }) + .update({ other_purpose: null }); + + await knex.schema.alterTable('soil_amendment_task_products_purpose_relationship', (table) => { + table.check( + `(other_purpose IS NOT NULL AND purpose_id = ${otherPurpose.id}) OR (other_purpose IS NULL)`, + [], + 'other_purpose_id_check', + ); + }); +}; + +export const down = async function (knex) { + await knex.schema.alterTable('soil_amendment_product', (table) => { + table.dropChecks(['moisture_percent_check']); + }); + + await knex.schema.alterTable('soil_amendment_task_products_purpose_relationship', (table) => { + table.dropChecks(['other_purpose_id_check']); + }); +}; diff --git a/packages/api/db/migration/20240703142448_data-migration-and-add-constraints-to-soil-amendment-task-and-relations.js b/packages/api/db/migration/20240703142448_data-migration-and-add-constraints-to-soil-amendment-task-and-relations.js new file mode 100644 index 0000000000..0c01972e2c --- /dev/null +++ b/packages/api/db/migration/20240703142448_data-migration-and-add-constraints-to-soil-amendment-task-and-relations.js @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2024 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 { addTableEnumConstraintSql, dropTableEnumConstraintSql } from '../util.js'; + +export const up = async function (knex) { + /*---------------------------------------- + Fix negative number values - only present on prod on soil_amendment task + ----------------------------------------*/ + await knex.raw(` + UPDATE soil_amendment_task_products + SET volume = -volume + WHERE volume IS NOT NULL + AND volume < 0 + `); + + await knex.raw(` + UPDATE soil_amendment_task_products + SET weight = -weight + WHERE weight IS NOT NULL + AND weight < 0 + `); + + /*---------------------------------------- + Add checkPositive contraint - to all new and touched number values + ----------------------------------------*/ + + await knex.schema.alterTable('soil_amendment_product', (table) => { + table.decimal('n', 36, 12).nullable().alter(); + table.check('n >= 0', [], 'check_positive_n'); + // P in this case stands for Phosphate P2O5 also known as "Available Phosphorus" + table.decimal('p', 36, 12).nullable().alter(); + table.check('p >= 0', [], 'check_positive_p'); + // K in this case stands for Potassium Oxide K2O also known as "Soluble Potash", + table.decimal('k', 36, 12).nullable().alter(); + table.check('k >= 0', [], 'check_positive_k'); + table.decimal('calcium', 36, 12).nullable().alter(); + table.check('calcium >= 0', [], 'check_positive_calcium'); + table.decimal('magnesium', 36, 12).nullable().alter(); + table.check('magnesium >= 0', [], 'check_positive_magnesium'); + table.decimal('sulfur', 36, 12).nullable().alter(); + table.check('sulfur >= 0', [], 'check_positive_sulfur'); + table.decimal('copper', 36, 12).nullable().alter(); + table.check('copper >= 0', [], 'check_positive_copper'); + table.decimal('manganese', 36, 12).nullable().alter(); + table.check('manganese >= 0', [], 'check_positive_manganese'); + table.decimal('boron', 36, 12).nullable().alter(); + table.check('boron >= 0', [], 'check_positive_boron'); + table.decimal('ammonium', 36, 12).nullable().alter(); + table.check('ammonium >= 0', [], 'check_positive_ammonium'); + table.decimal('nitrate', 36, 12).nullable().alter(); + table.check('nitrate >= 0', [], 'check_positive_nitrate'); + table.decimal('moisture_content_percent', 36, 12).nullable().alter(); + table.check('moisture_content_percent >= 0', [], 'check_positive_moisture_content'); + }); + + // Alter new product table + await knex.schema.alterTable('soil_amendment_task_products', (table) => { + table.decimal('weight', 36, 12).nullable().alter(); + table.check('weight >= 0', [], 'check_positive_weight'); + table.decimal('volume', 36, 12).nullable().alter(); + table.check('volume >= 0', [], 'check_positive_volume'); + // TODO: LF-4246 backfill data for percent_of_location_amended then make notNullable and defaulted to 100 + table.decimal('percent_of_location_amended', 36, 12).nullable().alter(); + table.check( + 'percent_of_location_amended >= 0', + [], + 'check_positive_percent_of_location_amended', + ); + // TODO: LF-4246 backfill data for total_area_amended in m2 then make notNullable + table.decimal('total_area_amended', 36, 12).nullable().alter(); + table.check('total_area_amended >= 0', [], 'check_positive_total_area_amended'); + }); + + // Alter soil amendmendment task + await knex.schema.alterTable('soil_amendment_task', (table) => { + table.decimal('furrow_hole_depth', 36, 12).nullable().alter(); + table.check('furrow_hole_depth >= 0', [], 'check_positive_furrow_hole_depth'); + }); + + // Alter cleaning task for volume and weight + await knex.schema.alterTable('cleaning_task', (table) => { + table.decimal('weight', 36, 12).nullable().alter(); + table.check('weight >= 0', [], 'check_positive_weight'); + table.decimal('volume', 36, 12).nullable().alter(); + table.check('volume >= 0', [], 'check_positive_volume'); + }); + + // Alter pest control task for volume and weight + await knex.schema.alterTable('pest_control_task', (table) => { + table.decimal('weight', 36, 12).nullable().alter(); + table.check('weight >= 0', [], ['check_positive_weight']); + table.decimal('volume', 36, 12).nullable().alter(); + table.check('volume >= 0', [], ['check_positive_volume']); + }); + + /*---------------------------------------- + Add type constraint to product + ----------------------------------------*/ + await knex.raw(dropTableEnumConstraintSql('product', 'type')); + await knex.schema.alterTable('product', (table) => { + table.text('type').notNullable().alter(); + }); + await knex.raw( + addTableEnumConstraintSql('product', 'type', [ + 'soil_amendment_task', + 'pest_control_task', + 'cleaning_task', + ]), + ); + + /*---------------------------------------- + Repair created_by and and updated_by on soil_amendment_task_product + ----------------------------------------*/ + const allTaskProducts = await knex('soil_amendment_task_products').select('id', 'task_id'); + for (const tp of allTaskProducts) { + const task = await knex('task') + .select('created_by_user_id', 'updated_by_user_id') + .where({ task_id: tp.task_id }) + .first(); + await knex('soil_amendment_task_products').where('id', tp.id).update({ + created_by_user_id: task.created_by_user_id, + updated_by_user_id: task.updated_by_user_id, + }); + } +}; + +export const down = async function (knex) { + /*---------------------------------------- + Fix negative number values - only present on prod on soil_amendment task + ----------------------------------------*/ + // Cannot be rolled back + + /*---------------------------------------- + Add checkPositive contraint - to all new and touched number values + ----------------------------------------*/ + await knex.schema.alterTable('soil_amendment_product', (table) => { + table.dropChecks([ + 'check_positive_n', + 'check_positive_p', + 'check_positive_k', + 'check_positive_calcium', + 'check_positive_magnesium', + 'check_positive_sulfur', + 'check_positive_copper', + 'check_positive_manganese', + 'check_positive_boron', + 'check_positive_ammonium', + 'check_positive_nitrate', + 'check_positive_moisture_content', + ]); + }); + + // Alter new product table + await knex.schema.alterTable('soil_amendment_task_products', (table) => { + table.dropChecks([ + 'check_positive_weight', + 'check_positive_volume', + 'check_positive_percent_of_location_amended', + 'check_positive_total_area_amended', + ]); + }); + + // Alter soil amendmendment task + await knex.schema.alterTable('soil_amendment_task', (table) => { + table.dropChecks(['check_positive_furrow_hole_depth']); + }); + + // Alter cleaning task for volume and weight + await knex.schema.alterTable('cleaning_task', (table) => { + table.dropChecks(['check_positive_weight', 'check_positive_volume']); + }); + + // Alter pest control task for volume and weight + await knex.schema.alterTable('pest_control_task', (table) => { + table.dropChecks(['check_positive_weight', 'check_positive_volume']); + }); + + /*---------------------------------------- + Add type constraint to product + ----------------------------------------*/ + await knex.raw(dropTableEnumConstraintSql('product', 'type')); + await knex.schema.alterTable('product', (table) => { + table.text('type').nullable().alter(); + }); + await knex.raw( + addTableEnumConstraintSql('product', 'type', [ + 'soil_amendment_task', + 'pest_control_task', + 'cleaning_task', + ]), + ); + + /*---------------------------------------- + Repair created_by and and updated_by on soil_amendment_task_product + ----------------------------------------*/ + const allTaskProducts = await knex('soil_amendment_task_products').select('id', 'task_id'); + for (const tp of allTaskProducts) { + const litefarmDBId = 1; + await knex('soil_amendment_task_products') + .where('id', tp.id) + .update({ created_by_user_id: litefarmDBId, updated_by_user_id: litefarmDBId }); + } +}; diff --git a/packages/api/db/migration/20240717190534_update_organic_certification_type.js b/packages/api/db/migration/20240717190534_update_organic_certification_type.js new file mode 100644 index 0000000000..bb09d877c5 --- /dev/null +++ b/packages/api/db/migration/20240717190534_update_organic_certification_type.js @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 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 up = async function (knex) { + await knex('certifications') + .update({ + certification_translation_key: 'THIRD_PARTY_ORGANIC', + certification_type: 'Third-party Organic', + }) + .where({ certification_translation_key: 'ORGANIC', certification_type: 'Organic' }); +}; + +export const down = async function (knex) { + await knex('certifications') + .update({ certification_translation_key: 'ORGANIC', certification_type: 'Organic' }) + .where({ + certification_translation_key: 'THIRD_PARTY_ORGANIC', + certification_type: 'Third-party Organic', + }); +}; diff --git a/packages/api/db/migration/20240719095056_add_uniqueness_check_on_task_product.js b/packages/api/db/migration/20240719095056_add_uniqueness_check_on_task_product.js new file mode 100644 index 0000000000..935eb9038f --- /dev/null +++ b/packages/api/db/migration/20240719095056_add_uniqueness_check_on_task_product.js @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 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 up = async function (knex) { + /*---------------------------------------- + Add uniqueness check for (task_id, product_id) composite - data deletion for local + beta only + ----------------------------------------*/ + const taskProductsWithDuplicates = await knex + .select('task_id', 'product_id') + .count('*', { as: 'cnt' }) + .from('soil_amendment_task_products') + .groupBy('task_id', 'product_id') + .havingRaw('COUNT(*) > 1') + .orderBy('task_id', 'asc'); + for (const taskProduct of taskProductsWithDuplicates) { + const duplicates = await knex + .select('*') + .table('soil_amendment_task_products') + .where({ task_id: taskProduct.task_id, product_id: taskProduct.product_id }) + .orderBy('created_at', 'asc'); + const notDeletedDuplicates = duplicates.filter((dupe) => !dupe.deleted); + let keep; + let softDelete; + if (notDeletedDuplicates.length === 0) { + keep = duplicates.pop(); + await knex('soil_amendment_task_products').where('id', keep.id).update({ deleted: false }); + softDelete = duplicates; + } else if (notDeletedDuplicates.length >= 1) { + //Choose only or latest task_product with deleted false + keep = notDeletedDuplicates.pop(); + softDelete = duplicates.filter((dupe) => dupe.id !== keep.id); + } + for (const deleteable of softDelete) { + await knex('soil_amendment_task_products') + .update({ deleted: true }) + .where('id', deleteable.id); + } + } + + // Add the partial unique index using raw SQL + // Knex partial indexes not working correctly + await knex.raw(` + CREATE UNIQUE INDEX task_product_uniqueness_composite + ON soil_amendment_task_products(task_id, product_id) + WHERE deleted = false; + `); +}; + +export const down = async function (knex) { + /*---------------------------------------- + Add uniqueness check for (task_id, product_id) composite + ----------------------------------------*/ + await knex.schema.alterTable('soil_amendment_task_products', (table) => { + table.dropIndex(['task_id', 'product_id'], 'task_product_uniqueness_composite'); + }); +}; diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index 9d789b293b..bc738ac4d8 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "litefarm-api", - "version": "3.6.4.1", + "version": "3.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "litefarm-api", - "version": "3.6.1", + "version": "3.6.5", "dependencies": { "@aws-sdk/client-s3": "^3.456.0", "@googlemaps/google-maps-services-js": "^3.3.14", diff --git a/packages/api/package.json b/packages/api/package.json index 5023757bc9..ba9e257d99 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "litefarm-api", - "version": "3.6.4.1", + "version": "3.6.5", "description": "LiteFarm API server", "main": "./api/src/server.js", "type": "module", diff --git a/packages/api/src/controllers/baseController.js b/packages/api/src/controllers/baseController.js index b804b5875b..bd7c1b43a5 100644 --- a/packages/api/src/controllers/baseController.js +++ b/packages/api/src/controllers/baseController.js @@ -27,6 +27,21 @@ function removeAdditionalProperties(model, data) { return lodash.pick(data, Object.keys(model.jsonSchema.properties)); } +function removeAdditionalPropertiesWithRelations(model, data) { + const modelKeys = Object.keys(model.jsonSchema.properties); + const relationKeys = Object.keys(model.relationMappings || {}); + + if (Array.isArray(data)) { + const arrayWithoutAdditionalProperties = data.map((obj) => { + return lodash.pick(obj, [...modelKeys, ...relationKeys]); + }); + return arrayWithoutAdditionalProperties; + } + //remove all the unnecessary properties + + return lodash.pick(data, [...modelKeys, ...relationKeys]); +} + export default { async get(model) { if (model.isSoftDelete) { @@ -173,7 +188,7 @@ export default { return await model .query(trx) .context({ user_id: req?.auth?.user_id, ...context }) - .upsertGraph(data, { insertMissing: true }); + .upsertGraph(removeAdditionalPropertiesWithRelations(model, data), { insertMissing: true }); }, // fetch an object and all of its related objects diff --git a/packages/api/src/controllers/managementPlanController.js b/packages/api/src/controllers/managementPlanController.js index 1bece709fe..d37cec6d4e 100644 --- a/packages/api/src/controllers/managementPlanController.js +++ b/packages/api/src/controllers/managementPlanController.js @@ -58,7 +58,7 @@ const managementPlanController = { const managementPlanGraph = await ManagementPlanModel.query(trx) .where('management_plan_id', management_plan_id) .withGraphFetched( - 'crop_management_plan.[planting_management_plans.[managementTasks.[task.[pest_control_task, irrigation_task, scouting_task, soil_task, soil_amendment_task, field_work_task, harvest_task, cleaning_task, locationTasks]], plant_task.[task.[locationTasks]], transplant_task.[task.[locationTasks]], bed_method, container_method, broadcast_method, row_method]]', + 'crop_management_plan.[planting_management_plans.[managementTasks.[task.[pest_control_task, irrigation_task, scouting_task, soil_task, soil_amendment_task, soil_amendment_task_products.[purpose_relationships], field_work_task, harvest_task, cleaning_task, locationTasks]], plant_task.[task.[locationTasks]], transplant_task.[task.[locationTasks]], bed_method, container_method, broadcast_method, row_method]]', ) .modifyGraph( 'crop_management_plan.[planting_management_plans.managementTasks]', @@ -404,10 +404,11 @@ const managementPlanController = { }), ); - await TaskModel.query(trx) - .context(req.auth) - .whereIn('task_id', taskIdsRelatedToOneManagementPlan) - .delete(); + await Promise.all( + taskIdsRelatedToOneManagementPlan.map(async (task_id) => { + await TaskModel.deleteTaskAndTaskProduct(req.auth, task_id, trx); + }), + ); const taskIdsRelatedToManyManagementPlans = tasksWithManagementPlanCount .filter(({ count }) => Number(count) > 1) diff --git a/packages/api/src/controllers/organicCertifierSurveyController.js b/packages/api/src/controllers/organicCertifierSurveyController.js index feec07ba28..264e02a5aa 100644 --- a/packages/api/src/controllers/organicCertifierSurveyController.js +++ b/packages/api/src/controllers/organicCertifierSurveyController.js @@ -263,7 +263,8 @@ const organicCertifierSurveyController = { ` SELECT DISTINCT p.name, p.supplier, - sat.product_quantity, + satp.volume, + satp.weight, CASE WHEN t.complete_date is null THEN t.due_date @@ -272,8 +273,8 @@ const organicCertifierSurveyController = { t.task_id, p.on_permitted_substances_list FROM task t - JOIN soil_amendment_task sat ON sat.task_id = t.task_id - JOIN product p ON p.product_id = sat.product_id + JOIN soil_amendment_task_products satp ON satp.task_id = t.task_id + JOIN product p ON p.product_id = satp.product_id JOIN location_tasks tl ON t.task_id = tl.task_id JOIN location l ON tl.location_id = l.location_id JOIN (SELECT location_id @@ -292,6 +293,7 @@ const organicCertifierSurveyController = { AND abandon_date IS NULL AND p.farm_id = :farm_id AND t.deleted = false + AND satp.deleted = false `, { to_date, from_date, farm_id }, ); @@ -322,8 +324,9 @@ const organicCertifierSurveyController = { const cleaningTask = await knex.raw( ` SELECT p.name, - p.supplier, - ct.product_quantity, + p.supplier, + ct.volume, + ct.weight, CASE WHEN t.complete_date is null THEN t.due_date @@ -625,7 +628,8 @@ const organicCertifierSurveyController = { ` SELECT DISTINCT p.name, p.supplier, - pct.product_quantity, + pct.volume, + pct.weight, t.complete_date::date as date_used, CASE WHEN t.complete_date is null THEN t.due_date @@ -663,7 +667,8 @@ const organicCertifierSurveyController = { ` SELECT DISTINCT p.name, p.supplier, - pct.product_quantity, + pct.volume, + pct.weight, CASE WHEN t.complete_date is null THEN t.due_date diff --git a/packages/api/src/controllers/productController.js b/packages/api/src/controllers/productController.js index f40910b4b6..721ed8dab3 100644 --- a/packages/api/src/controllers/productController.js +++ b/packages/api/src/controllers/productController.js @@ -14,9 +14,9 @@ */ import baseController from '../controllers/baseController.js'; - import ProductModel from '../models/productModel.js'; import { transaction, Model } from 'objection'; +import { handleObjectionError } from '../util/errorCodes.js'; const productController = { getProductsByFarm() { @@ -28,7 +28,8 @@ const productController = { .whereNotDeleted() .where({ farm_id, - }); + }) + .withGraphFetched('soil_amendment_product'); return res.status(200).send(rows); } catch (error) { //handle more exceptions @@ -43,17 +44,37 @@ const productController = { return async (req, res) => { const trx = await transaction.start(Model.knex()); try { + const { farm_id } = req.headers; const data = req.body; - data.product_translation_key = data.name; - const result = await baseController.postWithResponse(ProductModel, data, req, { trx }); + const result = await ProductModel.query(trx) + .context({ user_id: req?.auth?.user_id }) + .insertGraph({ ...data, farm_id }); await trx.commit(); res.status(201).send(result); } catch (error) { - //handle more exceptions - await trx.rollback(); - res.status(400).json({ - error, - }); + await handleObjectionError(error, res, trx); + } + }; + }, + updateProduct() { + return async (req, res) => { + const trx = await transaction.start(Model.knex()); + try { + const { farm_id } = req.headers; + const { product_id } = req.params; + const data = req.body; + + // This will replace the entire related object (e.g. soil_amendment_product) so keep that in mind when constructing the request + await baseController.upsertGraph( + ProductModel, + { ...data, farm_id, product_id: parseInt(product_id) }, + req, + { trx }, + ); + await trx.commit(); + res.status(204).send(); + } catch (error) { + await handleObjectionError(error, res, trx); } }; }, diff --git a/packages/api/src/controllers/soilAmendmentFertiliserTypeController.js b/packages/api/src/controllers/soilAmendmentFertiliserTypeController.js new file mode 100644 index 0000000000..7d99e8ac9d --- /dev/null +++ b/packages/api/src/controllers/soilAmendmentFertiliserTypeController.js @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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 soilAmendmentFertiliserTypeModel from '../models/soilAmendmentFertiliserTypeModel.js'; + +const soilAmendmentFertiliserTypeController = { + getSoilAmendmentFertiliserTypes() { + return async (_req, res) => { + try { + const rows = await soilAmendmentFertiliserTypeModel.query(); + return res.status(200).send(rows); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; + }, +}; + +export default soilAmendmentFertiliserTypeController; diff --git a/packages/api/src/controllers/soilAmendmentMethodController.js b/packages/api/src/controllers/soilAmendmentMethodController.js new file mode 100644 index 0000000000..dc7d234fa8 --- /dev/null +++ b/packages/api/src/controllers/soilAmendmentMethodController.js @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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 soilAmendmentMethodModel from '../models/soilAmendmentMethodModel.js'; + +const soilAmendmentMethodController = { + getSoilAmendmentMethods() { + return async (_req, res) => { + try { + const rows = await soilAmendmentMethodModel.query(); + return res.status(200).send(rows); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; + }, +}; + +export default soilAmendmentMethodController; diff --git a/packages/api/src/controllers/soilAmendmentPurposeController.js b/packages/api/src/controllers/soilAmendmentPurposeController.js new file mode 100644 index 0000000000..724b58ddfe --- /dev/null +++ b/packages/api/src/controllers/soilAmendmentPurposeController.js @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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 soilAmendmentPurposeModel from '../models/soilAmendmentPurposeModel.js'; + +const soilAmendmentPurposeController = { + getSoilAmendmentPurposes() { + return async (_req, res) => { + try { + const rows = await soilAmendmentPurposeModel.query(); + return res.status(200).send(rows); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; + }, +}; + +export default soilAmendmentPurposeController; diff --git a/packages/api/src/controllers/taskController.js b/packages/api/src/controllers/taskController.js index 8a19d08ccd..0dc257a85e 100644 --- a/packages/api/src/controllers/taskController.js +++ b/packages/api/src/controllers/taskController.js @@ -22,6 +22,7 @@ import ManagementTasksModel from '../models/managementTasksModel.js'; import TransplantTaskModel from '../models/transplantTaskModel.js'; import PlantTaskModel from '../models/plantTaskModel.js'; import HarvestUse from '../models/harvestUseModel.js'; +import SoilAmendmentTaskProductsModel from '../models/soilAmendmentTaskProductsModel.js'; import NotificationUser from '../models/notificationUserModel.js'; import User from '../models/userModel.js'; import { typesOfTask } from './../middleware/validation/task.js'; @@ -32,14 +33,6 @@ import Location from '../models/locationModel.js'; import TaskTypeModel from '../models/taskTypeModel.js'; import baseController from './baseController.js'; const adminRoles = [1, 2, 5]; -// const isDateInPast = (date) => { -// const today = new Date(); -// const newDate = new Date(date); -// if (newDate.setUTCHours(0, 0, 0, 0) < today.setUTCHours(0, 0, 0, 0)) { -// return true; -// } -// return false; -// }; async function getTaskAssigneeAndFinalWage(farm_id, user_id, task_id) { const { @@ -47,8 +40,8 @@ async function getTaskAssigneeAndFinalWage(farm_id, user_id, task_id) { assignee_role_id, wage_at_moment, override_hourly_wage, - } = await TaskModel.getTaskAssignee(task_id); - const { role_id } = await UserFarmModel.getUserRoleId(user_id); + } = await TaskModel.getTaskAssignee(task_id, farm_id); + const { role_id } = await UserFarmModel.getUserRoleId(user_id, farm_id); if (!canCompleteTask(assignee_user_id, assignee_role_id, user_id, role_id)) { throw new Error("Not authorized to complete other people's task"); } @@ -72,18 +65,51 @@ async function updateTaskWithCompletedData( data, wagePatchData, nonModifiable, + typeOfTask, ) { - const task = await TaskModel.query(trx) - .context({ user_id }) - .upsertGraph( - { task_id, ...data, ...wagePatchData }, - { - noUpdate: nonModifiable, - noDelete: true, - noInsert: true, - }, - ); - return task; + if (typeOfTask === 'soil_amendment_task') { + const { soil_amendment_task_products } = data; + + if (soil_amendment_task_products) { + // Temporarily soft delete all with task_id since there is no constraint on deletions + await SoilAmendmentTaskProductsModel.query(trx) + .context({ user_id }) + .update({ deleted: true }) + .where('task_id', task_id); + + // Set deleted false for all in update query + soil_amendment_task_products.forEach((taskProduct) => { + taskProduct.deleted = false; + }); + } + + // Allows the insertion of missing data if no id present + // Soft deletes table rows with soft delete option and hard deletes ones without + const task = await TaskModel.query(trx) + .context({ user_id }) + .upsertGraph( + { task_id, ...data, ...wagePatchData }, + { + noUpdate: nonModifiable, + noDelete: nonModifiable, + noInsert: nonModifiable, + insertMissing: true, + }, + ); + return task; + } else { + const task = await TaskModel.query(trx) + .context({ user_id }) + .upsertGraph( + { task_id, ...data, ...wagePatchData }, + { + noUpdate: nonModifiable, + noDelete: true, + noInsert: true, + }, + ); + return task; + } } const taskController = { @@ -247,33 +273,15 @@ const taskController = { } = req.body; const checkTaskStatus = await TaskModel.getTaskStatus(task_id); - if (checkTaskStatus.complete_date || checkTaskStatus.abandon_date) { - return res.status(400).send('Task has already been completed or abandoned'); - } const { - owner_user_id, assignee_user_id, wage_at_moment, override_hourly_wage, } = await TaskModel.query() - .select('owner_user_id', 'assignee_user_id', 'wage_at_moment', 'override_hourly_wage') + .select('assignee_user_id', 'wage_at_moment', 'override_hourly_wage') .where({ task_id }) .first(); - const isUserTaskOwner = user_id === owner_user_id; - const isUserTaskAssignee = user_id === assignee_user_id; - const hasAssignee = assignee_user_id !== null; - // TODO: move to middleware - // cannot abandon task if user is worker and not assignee and not creator - if (!adminRoles.includes(req.role) && !isUserTaskOwner && !isUserTaskAssignee) { - return res - .status(403) - .send('A worker who is not assignee or owner of task cannot abandon it'); - } - // cannot abandon an unassigned task with rating or duration - if (!hasAssignee && (happiness || duration)) { - return res.status(400).send('An unassigned task should not be rated or have time clocked'); - } let wage = { amount: 0 }; if (assignee_user_id) { @@ -319,13 +327,14 @@ const taskController = { return async (req, res, next) => { try { // Do not allow to create a task if location is deleted + const locations = req.body.locations; if ( - await baseController.isDeleted(null, Location, { - [Location.idColumn]: req.body.locations[0]?.location_id, - }) - ) { + locations?.length && + (await baseController.isDeleted(null, Location, { + [Location.idColumn]: locations[0].location_id, + })) + ) return res.status(409).send('location deleted'); - } // OC: the "noInsert" rule will not fail if a relationship is present in the graph. // it will just ignore the insert on it. This is just a 2nd layer of protection @@ -334,41 +343,8 @@ const taskController = { const { user_id } = req.auth; data.owner_user_id = user_id; - // Filter out deleted management plans from task if (data.managementPlans && data.managementPlans.length > 0) { - const plantingManagementPlanIds = data.managementPlans.map( - ({ planting_management_plan_id }) => planting_management_plan_id, - ); - - const plantingManagementPlans = await PlantingManagementPlanModel.query() - .context(req.auth) - .whereIn('planting_management_plan_id', plantingManagementPlanIds); - - const managementPlanIds = plantingManagementPlans.map( - ({ management_plan_id }) => management_plan_id, - ); - - const validManagementPlans = await ManagementPlanModel.query() - .context(req.auth) - .whereIn('management_plan_id', managementPlanIds) - .where('deleted', false); - - const validManagementPlanIds = validManagementPlans.map( - ({ management_plan_id }) => management_plan_id, - ); - - // Return error if task is associated with only a deleted plan - if (validManagementPlanIds.length === 0) { - return res.status(404).send('Management plan not found'); - } - - const validPlantingMangementPlans = plantingManagementPlans - .filter(({ management_plan_id }) => validManagementPlanIds.includes(management_plan_id)) - .map(({ planting_management_plan_id }) => planting_management_plan_id); - - data.managementPlans = data.managementPlans.filter(({ planting_management_plan_id }) => - validPlantingMangementPlans.includes(planting_management_plan_id), - ); + data.managementPlans = await filterOutDeletedManagementPlans(data, req); } data = await this.checkCustomDependencies(typeOfTask, data, req.headers.farm_id); @@ -384,7 +360,7 @@ const taskController = { const [task] = await TaskModel.query(trx) .withGraphFetched( ` - [locations.[location_defaults], managementPlans, taskType, soil_amendment_task, irrigation_task.[irrigation_type],scouting_task, + [locations.[location_defaults], managementPlans, taskType, soil_amendment_task, soil_amendment_task_products.[purpose_relationships], irrigation_task.[irrigation_type],scouting_task, field_work_task.[field_work_task_type], cleaning_task, pest_control_task, soil_task, harvest_task, plant_task] `, ) @@ -630,6 +606,7 @@ const taskController = { data, finalWage, nonModifiable, + typeOfTask, ); await patchManagementPlanStartDate(trx, req, typeOfTask); @@ -736,12 +713,25 @@ const taskController = { const graphTasks = await TaskModel.query() .whereNotDeleted() .withGraphFetched( - `[locations.[location_defaults], managementPlans, soil_amendment_task, field_work_task.[field_work_task_type], cleaning_task, pest_control_task, - harvest_task.[harvest_use], plant_task, transplant_task, irrigation_task.[irrigation_type]] + `[locations.[location_defaults], managementPlans, soil_amendment_task, soil_amendment_task_products(filterDeleted).[purpose_relationships], field_work_task.[field_work_task_type], cleaning_task, pest_control_task, harvest_task.[harvest_use], plant_task, transplant_task, irrigation_task.[irrigation_type]] `, ) .whereIn('task_id', taskIds); const filteredTasks = graphTasks.map(removeNullTypes); + + /* Clean before returning to frontend */ + const { + task_type_id: soilAmendmentTypeId, + } = await TaskTypeModel.query() + .whereNotDeleted() + .where({ farm_id: null, task_translation_key: 'SOIL_AMENDMENT_TASK' }) + .first(); + filteredTasks.forEach((task) => { + if (task.task_type_id !== soilAmendmentTypeId) { + delete task.soil_amendment_task_products; + } + }); + if (graphTasks) { res.status(200).send(filteredTasks); } @@ -796,11 +786,11 @@ const taskController = { const { user_id, farm_id } = req.headers; const checkTaskStatus = await TaskModel.getTaskStatus(task_id); - if (checkTaskStatus.complete_date || checkTaskStatus.abandon_date) { - return res.status(400).send('Task has already been completed or abandoned'); - } - const result = await TaskModel.deleteTask(task_id, req.auth); + const result = await TaskModel.transaction(async (trx) => { + return await TaskModel.deleteTaskAndTaskProduct(req.auth, task_id, trx); + }); + if (!result) return res.status(404).send('Task not found'); await sendTaskNotification( @@ -1058,3 +1048,39 @@ function canCompleteTask(assigneeUserId, assigneeRoleId, userId, userRoleId) { export default taskController; export { getTasksForFarm }; + +async function filterOutDeletedManagementPlans(data, req) { + const plantingManagementPlanIds = data.managementPlans.map( + ({ planting_management_plan_id }) => planting_management_plan_id, + ); + + const plantingManagementPlans = await PlantingManagementPlanModel.query() + .context(req.auth) + .whereIn('planting_management_plan_id', plantingManagementPlanIds); + + const managementPlanIds = plantingManagementPlans.map( + ({ management_plan_id }) => management_plan_id, + ); + + const validManagementPlans = await ManagementPlanModel.query() + .context(req.auth) + .whereIn('management_plan_id', managementPlanIds) + .where('deleted', false); + + const validManagementPlanIds = validManagementPlans.map( + ({ management_plan_id }) => management_plan_id, + ); + + // Throw error if task is associated with only a deleted plan + if (validManagementPlanIds.length === 0) { + throw new Error('Management plan not found'); + } + + const validPlantingMangementPlans = plantingManagementPlans + .filter(({ management_plan_id }) => validManagementPlanIds.includes(management_plan_id)) + .map(({ planting_management_plan_id }) => planting_management_plan_id); + + return data.managementPlans.filter(({ planting_management_plan_id }) => + validPlantingMangementPlans.includes(planting_management_plan_id), + ); +} diff --git a/packages/api/src/jobs/certification/record_i_generation.js b/packages/api/src/jobs/certification/record_i_generation.js index 0bff009304..815d8a99b8 100644 --- a/packages/api/src/jobs/certification/record_i_generation.js +++ b/packages/api/src/jobs/certification/record_i_generation.js @@ -3,7 +3,8 @@ import { i18n, t, tCrop } from '../locales/i18nt.js'; const dataToCellMapping = { name: 'A', supplier: 'B', - product_quantity: 'C', + weight: 'C', + volume: 'C', date_used: 'D', affected: 'E', on_permitted_substances_list: 'G', @@ -23,13 +24,23 @@ const boolToStringTransformation = (str) => { }; const dataTransformsMapping = { date_used: (date) => (date ? date.split('T')[0] : ''), - product_quantity: (quantity, measurement, isInputs) => - quantity ? getQuantity(quantity, measurement, isInputs) : 0, on_permitted_substances_list: boolToStringTransformation, }; -//TODO: fix unit after cleaning task unit is fixed -const getQuantity = (quantity, measurement, isInputs) => - (quantity * (measurement === 'imperial' ? (isInputs ? 2.20462 : 0.264172) : 1)).toFixed(2); + +const getQuantityWeight = (quantity, measurement) => { + if (measurement === 'imperial') { + // convert kg to lb + return `${(quantity * 2.20462).toFixed(2)} lb`; + } + return `${quantity} kg`; +}; +const getQuantityVolume = (quantity, measurement) => { + if (measurement === 'imperial') { + // convert l to gal + return `${(quantity * 0.264172).toFixed(2)} gal`; + } + return `${quantity} l`; +}; export default (data, exportId, from_date, to_date, farm_name, measurement, isInputs) => { return XlsxPopulate.fromBlankAsync().then((workbook) => { @@ -149,17 +160,7 @@ export default (data, exportId, from_date, to_date, farm_name, measurement, isIn workbook.sheet(0).cell('A9').value(rowNine).style({ wrapText: false }); workbook.sheet(0).cell('A10').value(t('RECORD_I.TABLE_COLUMN.PRODUCT_NAME')); workbook.sheet(0).cell('B10').value(t('RECORD_I.TABLE_COLUMN.SUPPLIER')); - workbook - .sheet(0) - .cell('C10') - .value( - t('RECORD_I.TABLE_COLUMN.QUANTITY', { - unit: (() => { - if (isInputs) return measurement === 'metric' ? 'kg' : 'lb'; - return measurement === 'metric' ? 'l' : 'gal'; - })(), - }), - ); + workbook.sheet(0).cell('C10').value(t('RECORD_I.TABLE_COLUMN.QUANTITY')); workbook.sheet(0).cell('D10').value(t('RECORD_I.TABLE_COLUMN.DATE_USED')); workbook.sheet(0).cell('E10').value(t('RECORD_I.TABLE_COLUMN.CROP_FIELD_APPLIED_TO')); workbook.sheet(0).cell('F10').value(t('RECORD_I.TABLE_COLUMN.NOTES')); @@ -212,10 +213,20 @@ export default (data, exportId, from_date, to_date, farm_name, measurement, isIn .filter((k) => k !== 'task_id') .map((k) => { const cell = `${dataToCellMapping[k]}${rowN}`; - const value = dataTransformsMapping[k] - ? dataTransformsMapping[k](row[k], measurement, isInputs) - : row[k]; - workbook.sheet(0).cell(cell).value(value); + if (k === 'weight' || k === 'volume') { + if (k === 'weight' && row[k]) { + const weightValue = getQuantityWeight(row[k], measurement); + workbook.sheet(0).cell(cell).value(weightValue); + } else if (k === 'volume' && row[k]) { + const volumeValue = getQuantityVolume(row[k], measurement); + workbook.sheet(0).cell(cell).value(volumeValue); + } + } else { + const value = dataTransformsMapping[k] + ? dataTransformsMapping[k](row[k], measurement, isInputs) + : row[k]; + workbook.sheet(0).cell(cell).value(value); + } }); }); return workbook.toFileAsync( diff --git a/packages/api/src/jobs/locales/en/translation.json b/packages/api/src/jobs/locales/en/translation.json index 26db36c29a..6bfa7764a0 100644 --- a/packages/api/src/jobs/locales/en/translation.json +++ b/packages/api/src/jobs/locales/en/translation.json @@ -92,7 +92,7 @@ "LISTED_IN_PSL": "Listed in the PSL? (Y/N)", "NOTES": "Notes", "PRODUCT_NAME": "Product name", - "QUANTITY": "Quantity ({{unit}})", + "QUANTITY": "Quantity", "SUPPLIER": "Brand Name or Source/Supplier" }, "VARIETALS": "Varietal(s)" diff --git a/packages/api/src/jobs/locales/es/translation.json b/packages/api/src/jobs/locales/es/translation.json index e2d060d6d4..42aaf5585c 100644 --- a/packages/api/src/jobs/locales/es/translation.json +++ b/packages/api/src/jobs/locales/es/translation.json @@ -92,11 +92,11 @@ "LISTED_IN_PSL": "¿Listado en la Lista de Sustancias Permitidas? (S/N)", "NOTES": "Notas", "PRODUCT_NAME": "Nombre de Producto", - "QUANTITY": "Cantidad ({{unit}})", + "QUANTITY": "Cantidad", "SUPPLIER": "Nombre de marca o fuente/proveedor" }, "VARIETALS": "Varietal(es)" }, "Y": "S", "YES": "Sí" -} \ No newline at end of file +} diff --git a/packages/api/src/jobs/locales/fr/translation.json b/packages/api/src/jobs/locales/fr/translation.json index 5bb3de9d01..9d0eb37c19 100644 --- a/packages/api/src/jobs/locales/fr/translation.json +++ b/packages/api/src/jobs/locales/fr/translation.json @@ -21,7 +21,7 @@ "NEW_AREA": "Zone (Ajoutée cette année)", "NON_ORGANIC": "Surface non-biologique en", "NON_PRODUCING": "Inexploitées", - "OPERATION NAME": "NOM DE L'EXPLOITATION", + "OPERATION_NAME": "NOM DE L'EXPLOITATION", "ORGANIC_AREA": "Zone", "PLEASE_VERIFY": "Veuillez vérifier les détails et apporter les modifications nécessaires chaque année. Si l’opération se déroule sur plusieurs sites, veuillez vous assurer de décrire chaque site séparément. Veuillez vous référer à l'exemple d'onglet ci-dessous pour savoir comment remplir ce formulaire.", "PRODUCTION": "production", @@ -90,9 +90,9 @@ "CROP_FIELD_APPLIED_TO": "Culture/champ appliqué à ou unité de production utilisée dans", "DATE_USED": "Date utilisée", "LISTED_IN_PSL": "Figurant sur la liste des substances permises? (O/N)", - "Notes": "Notes", + "NOTES": "Notes", "PRODUCT_NAME": "Nom du produit", - "QUANTITY": "Quantité ({{unit}})", + "QUANTITY": "Quantité", "SUPPLIER": "Nom commercial ou source/fournisseur" }, "VARIETALS": "Variété(s)" diff --git a/packages/api/src/jobs/locales/pt/translation.json b/packages/api/src/jobs/locales/pt/translation.json index edbd8f4fe6..b31e70303e 100644 --- a/packages/api/src/jobs/locales/pt/translation.json +++ b/packages/api/src/jobs/locales/pt/translation.json @@ -92,11 +92,11 @@ "LISTED_IN_PSL": "Está na Lista de Substâncias Permitidas? (S/N)", "NOTES": "Observações", "PRODUCT_NAME": "Nome do Produto", - "QUANTITY": "Quantidade ({{unit}})", + "QUANTITY": "Quantidade", "SUPPLIER": "Nome da marca, fonte ou fornecedor" }, "VARIETALS": "Variedade(s)" }, "Y": "S", "YES": "Sim" -} \ No newline at end of file +} diff --git a/packages/api/src/middleware/acl/checkParamAgainstBody.js b/packages/api/src/middleware/acl/checkParamAgainstBody.js new file mode 100644 index 0000000000..b4298be83e --- /dev/null +++ b/packages/api/src/middleware/acl/checkParamAgainstBody.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2007 Free Software Foundation, Inc. + * This file (authFarmId.js) 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 . + */ + +const checkParamAgainstBody = (param) => async (req, res, next) => { + if (!(param in req.body)) { + return res.status(400).send('param should be provided in body'); + } else if (!(req.body[param] === parseInt(req.params[param]))) { + return res.status(400).send('param should match body'); + } + next(); +}; + +export default checkParamAgainstBody; diff --git a/packages/api/src/middleware/acl/hasFarmAccess.js b/packages/api/src/middleware/acl/hasFarmAccess.js index 4cf99787a6..9cfddb9b4f 100644 --- a/packages/api/src/middleware/acl/hasFarmAccess.js +++ b/packages/api/src/middleware/acl/hasFarmAccess.js @@ -29,6 +29,7 @@ const entitiesGetters = { taskManagementPlanAndLocation: fromTaskManagementPlanAndLocation, nomination_id: fromNomination, transplant_task: fromTransPlantTask, + product_id: fromProduct, }; import userFarmModel from '../../models/userFarmModel.js'; @@ -258,6 +259,10 @@ function fromOrganicCertifierSurvey(survey_id) { return knex('organicCertifierSurvey').where({ survey_id }).first(); } +function fromProduct(product_id) { + return knex('product').where({ product_id }).first(); +} + function sameFarm(object, farm) { return object.farm_id === farm; } diff --git a/packages/api/src/middleware/validation/checkProductValidity.js b/packages/api/src/middleware/validation/checkProductValidity.js new file mode 100644 index 0000000000..8e64457a9c --- /dev/null +++ b/packages/api/src/middleware/validation/checkProductValidity.js @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 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 { transaction } from 'objection'; +import { handleObjectionError } from '../../util/errorCodes.js'; +import ProductModel from '../../models/productModel.js'; +import { checkAndTrimString } from '../../util/util.js'; + +const taskProductRelationMap = { + soil_amendment_task: 'soil_amendment_product', + // pest_control_task: 'pest_control_product', + // cleaning_task: 'cleaning_product' +}; +const productCheckMap = { + soil_amendment_task: checkSoilAmendmentProduct, + // pest_control_task: checkPestControlProduct, + // cleaning_task: checkCleaningProduct +}; + +function checkSoilAmendmentProduct(res, sap) { + const elements = [ + 'n', + 'p', + 'k', + 'calcium', + 'magnesium', + 'sulfur', + 'copper', + 'manganese', + 'boron', + ]; + const molecularCompounds = ['ammonium', 'nitrate']; + // Check that element values are all positive + if (!elements.every((element) => !sap[element] || sap[element] >= 0)) { + return res.status(400).send('element values must all be positive'); + } + + // Check that element values do not exceed 100 if element_unit is percent + if ( + sap.elemental_unit === 'percent' && + elements.reduce((sum, element) => sum + (sap[element] || 0), 0) > 100 + ) { + return res.status(400).send('percent elemental values must not exceed 100'); + } + + // Check that compound values are all positive + if (!molecularCompounds.every((compound) => !sap[compound] || sap[compound] >= 0)) { + return res.status(400).send('compounds values must all be positive'); + } + + if (sap.moisture_content_percent && sap.moisture_content_percent < 0) { + return res.status(400).send('moisture content value must be positive'); + } +} + +export function checkProductValidity() { + return async (req, res, next) => { + const { farm_id } = req.headers; + const { product_id } = req.params; + const isCreatingNew = req.method === 'POST'; + + let { type, name } = req.body; + const { [taskProductRelationMap[type]]: productDetails } = req.body; + + if (productDetails) { + productCheckMap[type](res, productDetails); + } + + // Null empty strings + ['name', 'supplier'].forEach((key) => { + if (req.body[key]) { + req.body[key] = checkAndTrimString(req.body[key]); + } + }); + + const trx = await transaction.start(ProductModel.knex()); + + try { + if (product_id) { + const currentRecord = await ProductModel.query(trx).findById(product_id); + // Prevent changing type for now, prevents orphan task type products + if (type && type != currentRecord.type) { + return res.status(400).send('cannot change product type'); + } + + type = type ?? currentRecord.type; + name = name ?? currentRecord.name; + } + + // Prevents error on name uniqeness check + if (isCreatingNew && !name) { + return res.status(400).send('new product must have name'); + } + + // Prevents error on name uniqeness check + if (isCreatingNew && !type) { + return res.status(400).send('new product must have type'); + } + + const nonModifiableAssets = Object.values(taskProductRelationMap).filter((productType) => { + return productType !== taskProductRelationMap[type]; + }); + + if ( + isCreatingNew && + taskProductRelationMap[type] && + !req.body[taskProductRelationMap[type]] + ) { + return res.status(400).send('must have product details'); + } + + if (nonModifiableAssets.some((asset) => Object.hasOwn(req.body, asset))) { + return res.status(400).send('must not have other product type details'); + } + + // Check name uniqueness + const existingRecord = await ProductModel.query(trx) + .where({ farm_id }) + .andWhere({ type }) + .andWhere({ name }) + .whereNot({ product_id: product_id ?? null }) + .whereNotDeleted(); + + if (existingRecord.length) { + await trx.rollback(); + return res.status(409).send('Product with this name already exists'); + } + + await trx.commit(); + next(); + } catch (error) { + handleObjectionError(error, res, trx); + } + }; +} diff --git a/packages/api/src/middleware/validation/checkSoilAmendmentTaskProducts.js b/packages/api/src/middleware/validation/checkSoilAmendmentTaskProducts.js new file mode 100644 index 0000000000..e76866aadb --- /dev/null +++ b/packages/api/src/middleware/validation/checkSoilAmendmentTaskProducts.js @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2024 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 ProductModel from '../../models/productModel.js'; +import SoilAmendmentPurposeModel from '../../models/soilAmendmentPurposeModel.js'; + +export function checkSoilAmendmentTaskProducts() { + return async (req, res, next) => { + try { + const { soil_amendment_task_products } = req.body; + + if (!Array.isArray(soil_amendment_task_products)) { + return res.status(400).send('soil_amendment_task_products must be an array'); + } + + if (!soil_amendment_task_products.length) { + return res.status(400).send('soil_amendment_task_products is required'); + } + + if (!soil_amendment_task_products.some((rel) => !rel.deleted)) { + return res.status(400).send('at least one task product is required'); + } + + if (soil_amendment_task_products.some((rel) => rel.deleted)) { + res.status(400).send('Deleted task products should be omitted from request body'); + } + + for (const product of soil_amendment_task_products) { + if (!product.product_id) { + return res.status(400).send('product_id is required'); + } + + if (typeof product.volume !== 'number' && typeof product.weight !== 'number') { + return res.status(400).send('volume or weight is required'); + } + + if ( + typeof product.volume === 'number' && + !product.volume_unit && + !product.application_rate_volume_unit + ) { + return res.status(400).send('volume_unit and application_rate_volume_unit is required'); + } + + if ( + typeof product.weight === 'number' && + !product.weight_unit && + !product.application_rate_weight_unit + ) { + return res.status(400).send('weight_unit and application_rate_weight_unit is required'); + } + + if (!product.percent_of_location_amended || !product.total_area_amended) { + return res + .status(400) + .send('percent_of_location_amended and total_area_amended is required'); + } + + if (!Array.isArray(product.purpose_relationships)) { + return res.status(400).send('purpose_relationships must be an array'); + } + + // Currently prevents deletion of last relationship, allows deletion if not provided + if (!product.purpose_relationships?.length) { + return res.status(400).send('purpose_relationships is required'); + } + + const otherPurpose = await SoilAmendmentPurposeModel.query() + .where({ key: 'OTHER' }) + .first(); + + for (const relationship of product.purpose_relationships) { + if (relationship.purpose_id != otherPurpose.id && relationship.other_purpose) { + return res.status(400).send('other_purpose is for other purpose'); + } + } + + const existingProduct = await ProductModel.query() + .where({ + product_id: product.product_id, + farm_id: req.headers.farm_id, + type: 'soil_amendment_task', + }) + .first(); + + if (!existingProduct) { + return res + .status(400) + .send( + `Soil amendment product ${product.product_id} does not exist or does not belong to the given farm`, + ); + } + } + next(); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; +} diff --git a/packages/api/src/middleware/validation/checkTask.js b/packages/api/src/middleware/validation/checkTask.js new file mode 100644 index 0000000000..e8e6d20ba6 --- /dev/null +++ b/packages/api/src/middleware/validation/checkTask.js @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2024 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 TaskModel from '../../models/taskModel.js'; +import { checkSoilAmendmentTaskProducts } from './checkSoilAmendmentTaskProducts.js'; +const adminRoles = [1, 2, 5]; +const taskTypesRequiringProducts = ['soil_amendment_task']; + +export function noReqBodyCheckYet() { + return async (req, res, next) => { + next(); + }; +} + +const checkProductsMiddlewareMap = { + soil_amendment_task: checkSoilAmendmentTaskProducts, + cleaning_task: noReqBodyCheckYet, + pest_control_task: noReqBodyCheckYet, + irrigation_task: noReqBodyCheckYet, + scouting_task: noReqBodyCheckYet, + soil_task: noReqBodyCheckYet, + field_work_task: noReqBodyCheckYet, + harvest_task: noReqBodyCheckYet, + plant_task: noReqBodyCheckYet, + transplant_task: noReqBodyCheckYet, + custom_task: noReqBodyCheckYet, +}; + +export function checkAbandonTask() { + return async (req, res, next) => { + try { + const { task_id } = req.params; + const { user_id } = req.headers; + const { + abandonment_reason, + other_abandonment_reason, + happiness, + duration, + abandon_date, + } = req.body; + + // Notifications will not send without, and checks below will be faulty + if (!user_id) { + return res.status(400).send('must have user_id'); + } + + if (!abandonment_reason) { + return res.status(400).send('must have abandonment_reason'); + } + + if (abandonment_reason.toUpperCase() === 'OTHER' && !other_abandonment_reason) { + return res.status(400).send('must have other_abandonment_reason'); + } + + if (!abandon_date) { + return res.status(400).send('must have abandonment_date'); + } + + const checkTaskStatus = await TaskModel.getTaskStatus(task_id); + if (checkTaskStatus.complete_date || checkTaskStatus.abandon_date) { + return res.status(400).send('Task has already been completed or abandoned'); + } + + const { owner_user_id, assignee_user_id } = await TaskModel.query() + .select('owner_user_id', 'assignee_user_id') + .where({ task_id }) + .first(); + const isUserTaskOwner = user_id === owner_user_id; + const isUserTaskAssignee = user_id === assignee_user_id; + const hasAssignee = assignee_user_id !== null; + + // cannot abandon task if user is worker and not assignee and not creator + if (!adminRoles.includes(req.role) && !isUserTaskOwner && !isUserTaskAssignee) { + return res + .status(403) + .send('A worker who is not assignee or owner of task cannot abandon it'); + } + // cannot abandon an unassigned task with rating or duration + if (!hasAssignee && (happiness || duration)) { + return res.status(400).send('An unassigned task should not be rated or have time clocked'); + } + next(); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; +} + +export function checkCompleteTask(taskType) { + return async (req, res, next) => { + try { + const { task_id } = req.params; + const { user_id } = req.headers; + const { duration, complete_date } = req.body; + + if (!user_id) { + return res.status(400).send('must have user_id'); + } + + if (!duration) { + return res.status(400).send('must have duration'); + } + + if (!complete_date) { + return res.status(400).send('must have completion date'); + } + + const checkTaskStatus = await TaskModel.getTaskStatus(task_id); + if (checkTaskStatus.complete_date || checkTaskStatus.abandon_date) { + return res.status(400).send('Task has already been completed or abandoned'); + } + + const { assignee_user_id } = await TaskModel.query() + .select('assignee_user_id') + .where({ task_id }) + .first(); + + // cannot complete an unassigned task + const hasAssignee = assignee_user_id !== null; + if (!hasAssignee) { + return res.status(400).send('An unassigned task cannot be completed'); + } + + if (`${taskType}_products` in req.body) { + checkProductsMiddlewareMap[taskType]()(req, res, next); + } else { + next(); + } + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; +} + +export function checkCreateTask(taskType) { + return async (req, res, next) => { + try { + const { user_id } = req.headers; + + if (!user_id) { + return res.status(400).send('must have user_id'); + } + + if (!(taskType in req.body) && taskType !== 'custom_task') { + return res.status(400).send('must have task details body'); + } + + if (taskTypesRequiringProducts.includes(taskType) && !(`${taskType}_products` in req.body)) { + return res.status(400).send('task type requires products'); + } + + checkProductsMiddlewareMap[taskType]()(req, res, next); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; +} + +export function checkDeleteTask() { + return async (req, res, next) => { + try { + const { task_id } = req.params; + const { user_id } = req.headers; + + if (!user_id) { + return res.status(400).send('must have user_id'); + } + + const checkTaskStatus = await TaskModel.getTaskStatus(task_id); + if (checkTaskStatus?.complete_date || checkTaskStatus?.abandon_date) { + return res.status(400).send('Task has already been completed or abandoned'); + } + + next(); + } catch (error) { + console.error(error); + return res.status(500).json({ + error, + }); + } + }; +} diff --git a/packages/api/src/models/cleaningTaskModel.js b/packages/api/src/models/cleaningTaskModel.js index a583c2a27d..6bda998af0 100644 --- a/packages/api/src/models/cleaningTaskModel.js +++ b/packages/api/src/models/cleaningTaskModel.js @@ -17,6 +17,58 @@ import Model from './baseFormatModel.js'; import taskModel from './taskModel.js'; class CleaningTaskModel extends Model { + // TODO: LF-4263 remove stub and update controller and FE to accept volume and weight + $parseDatabaseJson(json) { + // Remember to call the super class's implementation. + json = super.$parseDatabaseJson(json); + if (json.weight && json.weight_unit) { + json.product_quantity = json.weight; + json.product_quantity_unit = json.weight_unit; + } else if (json.volume && json.volume_unit) { + json.product_quantity = json.volume; + json.product_quantity_unit = json.volume_unit; + } else if (!json.volume && !json.weight) { + json.product_quantity = null; + json.product_quantity_unit = null; + } + // Database checks prevent quantity && !quantity_unit + delete json.weight; + delete json.weight_unit; + delete json.volume; + delete json.volume_unit; + return json; + } + + // TODO: LF-4263 remove stub and update controller and FE to accept volume and weight + $formatDatabaseJson(json) { + // Remember to call the super class's implementation. + json = super.$formatDatabaseJson(json); + const weightUnits = ['g', 'lb', 'kg', 't', 'mt', 'oz']; + const volumeUnits = ['l', 'gal', 'ml', 'fl-oz']; + if (json.product_quantity) { + if (json.product_quantity_unit && weightUnits.includes(json.product_quantity_unit)) { + json.weight = json.product_quantity; + json.weight_unit = json.product_quantity_unit; + json.volume = null; + json.volume_unit = null; + } else if (json.product_quantity_unit && volumeUnits.includes(json.product_quantity_unit)) { + json.volume = json.product_quantity; + json.volume_unit = json.product_quantity_unit; + json.weight = null; + json.weight_unit = null; + } else { + json.volume = json.product_quantity; + //Database previously defaulted to 'l' + json.volume_unit = 'l'; + json.weight = null; + json.weight_unit = null; + } + } + delete json.product_quantity; + delete json.product_quantity_unit; + return json; + } + static get tableName() { return 'cleaning_task'; } diff --git a/packages/api/src/models/irrigationTaskModel.js b/packages/api/src/models/irrigationTaskModel.js index 4f8d2c9b99..599524d43d 100644 --- a/packages/api/src/models/irrigationTaskModel.js +++ b/packages/api/src/models/irrigationTaskModel.js @@ -103,6 +103,7 @@ class IrrigationTaskModel extends Model { application_depth: 'keep', application_depth_unit: 'keep', measuring_type: 'keep', + // TODO: revisit when copy allows location changing percent_of_location_irrigated: 'keep', default_location_flow_rate: 'omit', default_location_application_depth: 'omit', diff --git a/packages/api/src/models/pestControlTask.js b/packages/api/src/models/pestControlTask.js index c26652a011..b84c4fdaa0 100644 --- a/packages/api/src/models/pestControlTask.js +++ b/packages/api/src/models/pestControlTask.js @@ -26,6 +26,58 @@ class PestControlTask extends Model { return 'task_id'; } + // TODO: LF-4263 remove stub and update controller and FE to accept volume and weight + $parseDatabaseJson(json) { + // Remember to call the super class's implementation. + json = super.$parseDatabaseJson(json); + if (json.weight && json.weight_unit) { + json.product_quantity = json.weight; + json.product_quantity_unit = json.weight_unit; + } else if (json.volume && json.volume_unit) { + json.product_quantity = json.volume; + json.product_quantity_unit = json.volume_unit; + } else if (!json.volume && !json.weight) { + json.product_quantity = null; + json.product_quantity_unit = null; + } + // Database checks prevent quantity && !quantity_unit + delete json.weight; + delete json.weight_unit; + delete json.volume; + delete json.volume_unit; + return json; + } + + // TODO: LF-4263 remove stub and update controller and FE to accept volume and weight + $formatDatabaseJson(json) { + // Remember to call the super class's implementation. + json = super.$formatDatabaseJson(json); + const weightUnits = ['g', 'lb', 'kg', 't', 'mt', 'oz']; + const volumeUnits = ['l', 'gal', 'ml', 'fl-oz']; + if (json.product_quantity) { + if (json.product_quantity_unit && weightUnits.includes(json.product_quantity_unit)) { + json.weight = json.product_quantity; + json.weight_unit = json.product_quantity_unit; + json.volume = null; + json.volume_unit = null; + } else if (json.product_quantity_unit && volumeUnits.includes(json.product_quantity_unit)) { + json.volume = json.product_quantity; + json.volume_unit = json.product_quantity_unit; + json.weight = null; + json.weight_unit = null; + } else { + json.volume = json.product_quantity; + //Database previously defaulted to 'l' + json.volume_unit = 'l'; + json.weight = null; + json.weight_unit = null; + } + } + delete json.product_quantity; + delete json.product_quantity_unit; + return json; + } + $parseJson(json, opt) { // Remember to call the super class's implementation. json = super.$parseJson(json, opt); diff --git a/packages/api/src/models/productModel.js b/packages/api/src/models/productModel.js index 33ec41a7c9..0b03252d4a 100644 --- a/packages/api/src/models/productModel.js +++ b/packages/api/src/models/productModel.js @@ -1,6 +1,6 @@ /* - * Copyright (C) 2007 Free Software Foundation, Inc. - * This file (productModel.js) is part of LiteFarm. + * Copyright (c) 2021-2024 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 @@ -13,7 +13,9 @@ * GNU General Public License for more details, see . */ +import { Model } from 'objection'; import baseModel from './baseModel.js'; +import soilAmendmentProductModel from './soilAmendmentProductModel.js'; class ProductModel extends baseModel { static get tableName() { @@ -29,12 +31,12 @@ class ProductModel extends baseModel { static get jsonSchema() { return { type: 'object', - required: ['name', 'farm_id'], + required: ['name', 'farm_id', 'type'], properties: { product_id: { type: 'integer' }, name: { type: 'string' }, product_translation_key: { type: 'string' }, - supplier: { type: 'string' }, + supplier: { type: ['string', 'null'], maxLength: 255 }, on_permitted_substances_list: { type: ['string', 'null'], enum: ['YES', 'NO', 'NOT_SURE', null], @@ -49,6 +51,19 @@ class ProductModel extends baseModel { additionalProperties: false, }; } + + static get relationMappings() { + return { + soil_amendment_product: { + relation: Model.HasOneRelation, + modelClass: soilAmendmentProductModel, + join: { + from: 'product.product_id', + to: 'soil_amendment_product.product_id', + }, + }, + }; + } } export default ProductModel; diff --git a/packages/api/src/models/soilAmendmentFertiliserTypeModel.js b/packages/api/src/models/soilAmendmentFertiliserTypeModel.js new file mode 100644 index 0000000000..9b16611567 --- /dev/null +++ b/packages/api/src/models/soilAmendmentFertiliserTypeModel.js @@ -0,0 +1,42 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; + +class soilAmendmentFertiliserType extends Model { + static get tableName() { + return 'soil_amendment_fertiliser_type'; + } + + static get idColumn() { + return 'id'; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + required: ['key'], + properties: { + id: { type: 'integer' }, + key: { type: 'string' }, + }, + additionalProperties: false, + }; + } +} + +export default soilAmendmentFertiliserType; diff --git a/packages/api/src/models/soilAmendmentMethodModel.js b/packages/api/src/models/soilAmendmentMethodModel.js new file mode 100644 index 0000000000..2ec4394653 --- /dev/null +++ b/packages/api/src/models/soilAmendmentMethodModel.js @@ -0,0 +1,42 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; + +class soilAmendmentMethod extends Model { + static get tableName() { + return 'soil_amendment_method'; + } + + static get idColumn() { + return 'id'; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + required: ['key'], + properties: { + id: { type: 'integer' }, + key: { type: 'string' }, + }, + additionalProperties: false, + }; + } +} + +export default soilAmendmentMethod; diff --git a/packages/api/src/models/soilAmendmentProductModel.js b/packages/api/src/models/soilAmendmentProductModel.js new file mode 100644 index 0000000000..064899959a --- /dev/null +++ b/packages/api/src/models/soilAmendmentProductModel.js @@ -0,0 +1,55 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; + +class soilAmendmentProduct extends Model { + static get tableName() { + return 'soil_amendment_product'; + } + + static get idColumn() { + return 'product_id'; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + properties: { + product_id: { type: 'integer' }, + soil_amendment_fertiliser_type_id: { type: ['integer', 'null'] }, + n: { type: ['number', 'null'] }, + p: { type: ['number', 'null'] }, + k: { type: ['number', 'null'] }, + calcium: { type: ['number', 'null'] }, + magnesium: { type: ['number', 'null'] }, + sulfur: { type: ['number', 'null'] }, + copper: { type: ['number', 'null'] }, + manganese: { type: ['number', 'null'] }, + boron: { type: ['number', 'null'] }, + elemental_unit: { type: ['string', 'null'], enum: ['percent', 'ratio', 'ppm', 'mg/kg'] }, + ammonium: { type: ['number', 'null'] }, + nitrate: { type: ['number', 'null'] }, + molecular_compounds_unit: { type: ['string', 'null'], enum: ['ppm', 'mg/kg'] }, + moisture_content_percent: { type: ['number', 'null'] }, + }, + additionalProperties: false, + }; + } +} + +export default soilAmendmentProduct; diff --git a/packages/api/src/models/soilAmendmentPurposeModel.js b/packages/api/src/models/soilAmendmentPurposeModel.js new file mode 100644 index 0000000000..d7055858fa --- /dev/null +++ b/packages/api/src/models/soilAmendmentPurposeModel.js @@ -0,0 +1,42 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; + +class soilAmendmentPurpose extends Model { + static get tableName() { + return 'soil_amendment_purpose'; + } + + static get idColumn() { + return 'id'; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + required: ['key'], + properties: { + id: { type: 'integer' }, + key: { type: 'string' }, + }, + additionalProperties: false, + }; + } +} + +export default soilAmendmentPurpose; diff --git a/packages/api/src/models/soilAmendmentTaskModel.js b/packages/api/src/models/soilAmendmentTaskModel.js index d79768b466..205640204c 100644 --- a/packages/api/src/models/soilAmendmentTaskModel.js +++ b/packages/api/src/models/soilAmendmentTaskModel.js @@ -15,8 +15,9 @@ import Model from './baseFormatModel.js'; import taskModel from './taskModel.js'; -import productModel from './productModel.js'; +import soilAmendmentMethodModel from './soilAmendmentMethodModel.js'; +const furrowHoleDepthUnits = ['cm', 'in']; class SoilAmendmentTaskModel extends Model { static get tableName() { return 'soil_amendment_task'; @@ -25,27 +26,44 @@ class SoilAmendmentTaskModel extends Model { static get idColumn() { return 'task_id'; } + + async $beforeUpdate(queryContext) { + await super.$beforeUpdate(queryContext); + + if (this.method_id) { + const { key } = await soilAmendmentMethodModel + .query(queryContext.transaction) + .findById(this.method_id) + .select('key') + .first(); + + if (key !== 'OTHER') { + this.other_application_method = null; + } + + if (key !== 'FURROW_HOLE') { + this.furrow_hole_depth = null; + this.furrow_hole_depth_unit = null; + } + } + } + // Optional JSON schema. This is not the database schema! Nothing is generated // based on this. This is only used for validation. Whenever a model instance // is created it is checked against this schema. http://json-schema.org/. static get jsonSchema() { return { type: 'object', - required: [], - + required: ['method_id'], properties: { task_id: { type: 'integer' }, - purpose: { - type: 'string', - enum: ['structure', 'moisture_retention', 'nutrient_availability', 'ph', 'other'], - }, - other_purpose: { type: ['string', 'null'] }, - product_id: { type: 'integer', minimum: 0 }, - product_quantity: { type: 'number' }, - product_quantity_unit: { - type: 'string', - enum: ['g', 'lb', 'kg', 't', 'mt', 'oz', 'l', 'gal', 'ml', 'fl-oz'], + method_id: { type: ['integer', 'null'] }, + furrow_hole_depth: { type: ['number', 'null'] }, + furrow_hole_depth_unit: { + type: ['string', 'null'], + enum: [...furrowHoleDepthUnits, null], }, + other_application_method: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, }, additionalProperties: false, }; @@ -65,12 +83,12 @@ class SoilAmendmentTaskModel extends Model { to: 'task.task_id', }, }, - product: { + method: { relation: Model.BelongsToOneRelation, - modelClass: productModel, + modelClass: soilAmendmentMethodModel, join: { - from: 'soil_amendment_task.product_id', - to: 'product.product_id', + from: 'soil_amendment_task.method_id', + to: 'soil_amendment_method.id', }, }, }; @@ -82,14 +100,14 @@ class SoilAmendmentTaskModel extends Model { return { // jsonSchema() task_id: 'omit', - purpose: 'keep', - other_purpose: 'keep', - product_id: 'keep', - product_quantity: 'keep', - product_quantity_unit: 'keep', + method_id: 'keep', + furrow_hole_depth: 'keep', + furrow_hole_depth_unit: 'keep', + other_application_method: 'keep', // relationMappings task: 'omit', product: 'omit', + method: 'omit', }; } } diff --git a/packages/api/src/models/soilAmendmentTaskProductPurposeRelationshipModel.js b/packages/api/src/models/soilAmendmentTaskProductPurposeRelationshipModel.js new file mode 100644 index 0000000000..3053ee4dc4 --- /dev/null +++ b/packages/api/src/models/soilAmendmentTaskProductPurposeRelationshipModel.js @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; +import soilAmendmentPurposeModel from './soilAmendmentPurposeModel.js'; + +class soilAmendmentTaskProductPurposeRelationshipModel extends Model { + static get tableName() { + return 'soil_amendment_task_products_purpose_relationship'; + } + + static get idColumn() { + return ['task_products_id', 'purpose_id']; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + required: ['task_products_id', 'purpose_id'], + properties: { + task_products_id: { type: 'integer' }, + purpose_id: { type: 'integer' }, + other_purpose: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, + }, + additionalProperties: false, + }; + } + + static get relationMappings() { + // Import models here to prevent require loops. + return { + purpose: { + relation: Model.BelongsToOneRelation, + modelClass: soilAmendmentPurposeModel, + join: { + from: 'soil_amendment_task_products_purpose_relationship.purpose_id', + to: 'soil_amendment_purpose.id', + }, + }, + }; + } + + // Custom function used in copy crop plan + // Should contain all jsonSchema() and relationMappings() keys + static get templateMappingSchema() { + return { + // jsonSchema() + task_products_id: 'omit', + purpose_id: 'keep', + other_purpose: 'keep', + // relationMappings() + purpose: 'omit', + }; + } +} + +export default soilAmendmentTaskProductPurposeRelationshipModel; diff --git a/packages/api/src/models/soilAmendmentTaskProductsModel.js b/packages/api/src/models/soilAmendmentTaskProductsModel.js new file mode 100644 index 0000000000..6c67b7dd74 --- /dev/null +++ b/packages/api/src/models/soilAmendmentTaskProductsModel.js @@ -0,0 +1,171 @@ +/* + * Copyright 2024 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 Model from './baseFormatModel.js'; +import BaseModel from './baseModel.js'; +import productModel from './productModel.js'; +import taskModel from './taskModel.js'; +//import soilAmendmentPurposeModel from './soilAmendmentPurposeModel.js'; +import soilAmendmentTaskProductPurposeRelationshipModel from './soilAmendmentTaskProductPurposeRelationshipModel.js'; + +const metricWeightUnits = ['g', 'kg', 'mt']; +const imperialWeightUnits = ['oz', 'lb', 't']; +const weightUnits = [...metricWeightUnits, ...imperialWeightUnits]; +const applicationRateWeightUnits = [ + 'g/m2', + 'lb/ft2', + 'kg/m2', + 't/ft2', + 'mt/m2', + 'oz/ft2', + 'g/ha', + 'lb/ac', + 'kg/ha', + 't/ac', + 'mt/ha', + 'oz/ac', +]; +const metricVolumeUnits = ['ml', 'l']; +const imperialVolumeUnits = ['fl-oz', 'gal']; +const volumeUnits = [...metricVolumeUnits, ...imperialVolumeUnits]; +const applicationRateVolumeUnits = [ + 'l/m2', + 'gal/ft2', + 'ml/m2', + 'fl-oz/ft2', + 'l/ha', + 'gal/ac', + 'ml/ha', + 'fl-oz/ac', +]; + +class SoilAmendmentTaskProducts extends BaseModel { + static get tableName() { + return 'soil_amendment_task_products'; + } + + static get idColumn() { + return 'id'; + } + + // Optional JSON schema. This is not the database schema! Nothing is generated + // based on this. This is only used for validation. Whenever a model instance + // is created it is checked against this schema. http://json-schema.org/. + static get jsonSchema() { + return { + type: 'object', + // LF-4246 - null data present on prod + // required: ['product_id'], + // oneOf: [ + // { + // required: ['weight', 'weight_unit'], + // }, + // { + // required: ['volume', 'volume_unit'], + // }, + // ], + properties: { + id: { type: 'integer' }, + task_id: { type: 'integer' }, + product_id: { type: 'integer' }, + weight: { type: ['number', 'null'] }, + weight_unit: { + type: ['string', 'null'], + enum: [...weightUnits, null], + }, + application_rate_weight_unit: { + type: ['string', 'null'], + enum: [...applicationRateWeightUnits, null], + }, + volume: { type: ['number', 'null'] }, + volume_unit: { + type: ['string', 'null'], + enum: [...volumeUnits, null], + }, + application_rate_volume_unit: { + type: ['string', 'null'], + enum: [...applicationRateVolumeUnits, null], + }, + percent_of_location_amended: { type: ['number', 'null'] }, + total_area_amended: { type: ['number', 'null'] }, + ...super.baseProperties, + }, + additionalProperties: false, + }; + } + + static get relationMappings() { + // Import models here to prevent require loops. + return { + product: { + relation: Model.BelongsToOneRelation, + modelClass: productModel, + join: { + from: 'soil_amendment_task_products.product_id', + to: 'product.product_id', + }, + }, + task: { + relation: Model.BelongsToOneRelation, + modelClass: taskModel, + join: { + from: 'soil_amendment_task_products.task_id', + to: 'task.task_id', + }, + }, + purpose_relationships: { + relation: Model.HasManyRelation, + modelClass: soilAmendmentTaskProductPurposeRelationshipModel, + join: { + from: 'soil_amendment_task_products.id', + to: 'soil_amendment_task_products_purpose_relationship.task_products_id', + }, + }, + }; + } + + static modifiers = { + filterDeleted(query) { + const { ref } = SoilAmendmentTaskProducts; + query.where(ref('deleted'), false); + }, + }; + + // Custom function used in copy crop plan + // Should contain all jsonSchema() and relationMappings() keys + static get templateMappingSchema() { + return { + // jsonSchema() + id: 'omit', + task_id: 'omit', + product_id: 'keep', + weight: 'keep', + weight_unit: 'keep', + application_rate_weight_unit: 'keep', + volume: 'keep', + volume_unit: 'keep', + application_rate_volume_unit: 'keep', + // TODO: revisit when copy allows location changing + percent_of_location_amended: 'keep', + total_area_amended: 'keep', + // relationMappings + product: 'omit', + soil_amendment_task: 'omit', + purpose_relationships: 'edit', + }; + } +} + +export default SoilAmendmentTaskProducts; diff --git a/packages/api/src/models/taskModel.js b/packages/api/src/models/taskModel.js index cd4c706dd7..338ab61325 100644 --- a/packages/api/src/models/taskModel.js +++ b/packages/api/src/models/taskModel.js @@ -17,6 +17,7 @@ import Model from './baseFormatModel.js'; import BaseModel from './baseModel.js'; import soilAmendmentTaskModel from './soilAmendmentTaskModel.js'; +import soilAmendmentTaskProductsModel from './soilAmendmentTaskProductsModel.js'; import pestControlTask from './pestControlTask.js'; import irrigationTaskModel from './irrigationTaskModel.js'; import scoutingTaskModel from './scoutingTaskModel.js'; @@ -100,6 +101,14 @@ class TaskModel extends BaseModel { to: 'soil_amendment_task.task_id', }, }, + soil_amendment_task_products: { + relation: Model.HasManyRelation, + modelClass: soilAmendmentTaskProductsModel, + join: { + from: 'task.task_id', + to: 'soil_amendment_task_products.task_id', + }, + }, pest_control_task: { relation: Model.HasOneRelation, modelClass: pestControlTask, @@ -246,6 +255,7 @@ class TaskModel extends BaseModel { action_needed: 'omit', // relationMappings soil_amendment_task: 'edit', + soil_amendment_task_products: 'edit', pest_control_task: 'edit', irrigation_task: 'edit', scouting_task: 'edit', @@ -268,7 +278,7 @@ class TaskModel extends BaseModel { * @async * @returns {Object} - Object {assignee_user_id, assignee_role_id, wage_at_moment, override_hourly_wage} */ - static async getTaskAssignee(taskId) { + static async getTaskAssignee(taskId, farmId) { return await TaskModel.query() .whereNotDeleted() .join('users', 'task.assignee_user_id', 'users.user_id') @@ -279,7 +289,7 @@ class TaskModel extends BaseModel { 'users.user_id as assignee_user_id, role.role_id as assignee_role_id, task.wage_at_moment, task.override_hourly_wage', ), ) - .where('task.task_id', taskId) + .where({ 'task.task_id': taskId, 'uf.farm_id': farmId }) .first(); } @@ -414,9 +424,9 @@ class TaskModel extends BaseModel { }); } - static async deleteTask(task_id, user) { + static async deleteTask(task_id, user, trx) { try { - const deleteResponse = await TaskModel.query() + const deleteResponse = await TaskModel.query(trx) .context(user) .patchAndFetchById(task_id, { deleted: true }); return deleteResponse; @@ -424,6 +434,32 @@ class TaskModel extends BaseModel { return error; } } + + static async deleteTaskAndTaskProduct(user, task_id, trx) { + const taskTypesWithProducts = ['soil_amendment_task']; + + const taskType = await TaskModel.relatedQuery('taskType', trx).for(task_id).first(); + const { farm_id, task_translation_key } = taskType; + const taskTypeKey = task_translation_key?.toLowerCase(); + + const relatedProductTable = `${taskTypeKey}_products`; + + if (!farm_id && taskTypesWithProducts.includes(taskTypeKey)) { + // Mark related products as deleted + await TaskModel.relatedQuery(relatedProductTable, trx) + .for(task_id) + .context(user) + .patch({ deleted: true }); + // Mark the task itself as deleted and fetch the updated task + return await TaskModel.query(trx) + .withGraphFetched(relatedProductTable) + .context({ ...user, showHidden: true }) + .patchAndFetchById(task_id, { deleted: true }); + } else { + // If the task is custom or does not have associated product, delete just the task + return TaskModel.deleteTask(task_id, user, trx); + } + } } export default TaskModel; diff --git a/packages/api/src/models/userFarmModel.js b/packages/api/src/models/userFarmModel.js index e4650590e7..c68df254ba 100644 --- a/packages/api/src/models/userFarmModel.js +++ b/packages/api/src/models/userFarmModel.js @@ -147,12 +147,12 @@ class userFarm extends Model { * @async * @returns {number} Number corresponding to role_id. */ - static async getUserRoleId(userId) { + static async getUserRoleId(userId, farmId) { return userFarm .query() .join('role', 'userFarm.role_id', 'role.role_id') .select('role.role_id') - .where('userFarm.user_id', userId) + .where({ 'userFarm.user_id': userId, 'userFarm.farm_id': farmId }) .first(); } diff --git a/packages/api/src/routes/productRoute.js b/packages/api/src/routes/productRoute.js index dce912b738..73f42b8084 100644 --- a/packages/api/src/routes/productRoute.js +++ b/packages/api/src/routes/productRoute.js @@ -19,6 +19,7 @@ const router = express.Router(); import checkScope from '../middleware/acl/checkScope.js'; import productController from './../controllers/productController.js'; import hasFarmAccess from '../middleware/acl/hasFarmAccess.js'; +import { checkProductValidity } from '../middleware/validation/checkProductValidity.js'; // Get the crop on a bed router.get( @@ -29,9 +30,17 @@ router.get( router.post( '/', - hasFarmAccess({ body: 'farm_id' }), checkScope(['add:product']), + checkProductValidity(), productController.addProduct(), ); +router.patch( + '/:product_id', + hasFarmAccess({ params: 'product_id' }), + checkScope(['edit:product']), + checkProductValidity(), + productController.updateProduct(), +); + export default router; diff --git a/packages/api/src/routes/soilAmendmentFertiliserTypeRoute.js b/packages/api/src/routes/soilAmendmentFertiliserTypeRoute.js new file mode 100644 index 0000000000..a26f4f0df1 --- /dev/null +++ b/packages/api/src/routes/soilAmendmentFertiliserTypeRoute.js @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 Method. See the + * GNU General Public License for more details, see . + */ + +import express from 'express'; + +const router = express.Router(); +import soilAmendmentFertiliserTypeController from '../controllers/soilAmendmentFertiliserTypeController.js'; + +router.get('/', soilAmendmentFertiliserTypeController.getSoilAmendmentFertiliserTypes()); + +export default router; diff --git a/packages/api/src/routes/soilAmendmentMethodRoute.js b/packages/api/src/routes/soilAmendmentMethodRoute.js new file mode 100644 index 0000000000..87f26bc619 --- /dev/null +++ b/packages/api/src/routes/soilAmendmentMethodRoute.js @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 Method. See the + * GNU General Public License for more details, see . + */ + +import express from 'express'; + +const router = express.Router(); +import soilAmendmentMethodController from '../controllers/soilAmendmentMethodController.js'; + +router.get('/', soilAmendmentMethodController.getSoilAmendmentMethods()); + +export default router; diff --git a/packages/api/src/routes/soilAmendmentPurposeRoute.js b/packages/api/src/routes/soilAmendmentPurposeRoute.js new file mode 100644 index 0000000000..6730e1af58 --- /dev/null +++ b/packages/api/src/routes/soilAmendmentPurposeRoute.js @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 express from 'express'; + +const router = express.Router(); +import soilAmendmentPurposeController from '../controllers/soilAmendmentPurposeController.js'; + +router.get('/', soilAmendmentPurposeController.getSoilAmendmentPurposes()); + +export default router; diff --git a/packages/api/src/routes/taskRoute.js b/packages/api/src/routes/taskRoute.js index c0be4e1430..c3fd0e7340 100644 --- a/packages/api/src/routes/taskRoute.js +++ b/packages/api/src/routes/taskRoute.js @@ -25,6 +25,12 @@ import { } from '../middleware/validation/assignTask.js'; import taskController from '../controllers/taskController.js'; import { createOrPatchProduct } from '../middleware/validation/product.js'; +import { + checkAbandonTask, + checkCompleteTask, + checkCreateTask, + checkDeleteTask, +} from '../middleware/validation/checkTask.js'; router.patch( '/assign/:task_id', @@ -62,6 +68,7 @@ router.patch( '/abandon/:task_id', hasFarmAccess({ params: 'task_id' }), checkScope(['edit:task']), + checkAbandonTask(), taskController.abandonTask, ); @@ -82,7 +89,7 @@ router.post( modelMapping['soil_amendment_task'], hasFarmAccess({ mixed: 'taskManagementPlanAndLocation' }), isWorkerToSelfOrAdmin(), - createOrPatchProduct('soil_amendment_task'), + checkCreateTask('soil_amendment_task'), taskController.createTask('soil_amendment_task'), ); @@ -165,7 +172,7 @@ router.patch( modelMapping['soil_amendment_task'], hasFarmAccess({ params: 'task_id' }), checkScope(['edit:task']), - createOrPatchProduct('soil_amendment_task'), + checkCompleteTask('soil_amendment_task'), taskController.completeTask('soil_amendment_task'), ); @@ -273,6 +280,7 @@ router.delete( '/:task_id', hasFarmAccess({ params: 'task_id' }), checkScope(['delete:task']), + checkDeleteTask(), taskController.deleteTask, ); diff --git a/packages/api/src/server.js b/packages/api/src/server.js index 4f0ccca46d..57f01afba2 100644 --- a/packages/api/src/server.js +++ b/packages/api/src/server.js @@ -39,7 +39,7 @@ if (process.env.SENTRY_DSN && environment !== 'development') { // Automatically instrument Node.js libraries and frameworks ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(), ], - release: '3.6.4.1', + release: '3.6.5', // Set tracesSampleRate to 1.0 to capture 100% // of transactions for performance monitoring. // We recommend adjusting this value in production @@ -129,6 +129,9 @@ import cropVarietyRoutes from './routes/cropVarietyRoute.js'; import fieldRoutes from './routes/fieldRoute.js'; import saleRoutes from './routes/saleRoute.js'; import taskTypeRoutes from './routes/taskTypeRoute.js'; +import soilAmendmentMethodRoute from './routes/soilAmendmentMethodRoute.js'; +import soilAmendmentPurposeRoute from './routes/soilAmendmentPurposeRoute.js'; +import soilAmendmentFertiliserTypeRoute from './routes/soilAmendmentFertiliserTypeRoute.js'; import userRoutes from './routes/userRoute.js'; import farmExpenseRoute from './routes/farmExpenseRoute.js'; import farmExpenseTypeRoute from './routes/farmExpenseTypeRoute.js'; @@ -272,6 +275,9 @@ app .use('/sale', saleRoutes) .use('/revenue_type', revenueTypeRoute) .use('/task_type', taskTypeRoutes) + .use('/soil_amendment_purposes', soilAmendmentPurposeRoute) + .use('/soil_amendment_methods', soilAmendmentMethodRoute) + .use('/soil_amendment_fertiliser_types', soilAmendmentFertiliserTypeRoute) .use('/user', userRoutes) .use('/expense', farmExpenseRoute) .use('/expense_type', farmExpenseTypeRoute) diff --git a/packages/api/src/util/copyCropPlan.js b/packages/api/src/util/copyCropPlan.js index 6cabd84088..5d15803dd8 100644 --- a/packages/api/src/util/copyCropPlan.js +++ b/packages/api/src/util/copyCropPlan.js @@ -25,6 +25,8 @@ import PestControlTaskModel from '../models/pestControlTask.js'; import ScoutingTaskModel from '../models/scoutingTaskModel.js'; import SoilTaskModel from '../models/soilTaskModel.js'; import SoilAmendmentTaskModel from '../models/soilAmendmentTaskModel.js'; +import SoilAmendmentTaskProductsModel from '../models/soilAmendmentTaskProductsModel.js'; +import soilAmendmentTaskProductPurposeRelationshipModel from '../models/soilAmendmentTaskProductPurposeRelationshipModel.js'; import IrrigationTaskModel from '../models/irrigationTaskModel.js'; import HarvestTaskModel from '../models/harvestTaskModel.js'; import FieldWorkTaskModel from '../models/fieldWorkTaskModel.js'; @@ -35,7 +37,6 @@ import BedMethodModel from '../models/bedMethodModel.js'; import ContainerMethodModel from '../models/containerMethodModel.js'; import _omit from 'lodash/omit.js'; import { getUUIDMap } from '../util/util.js'; - /** * Formats and returns an array of management plan data based on the provided management plan group. * @@ -319,6 +320,28 @@ export const getManagementPlanTemplateGraph = ( ), } : null, + soil_amendment_task_products: managementTask.task.soil_amendment_task_products + ? managementTask.task.soil_amendment_task_products.map((taskProduct) => { + return { + ..._omit( + taskProduct, + getPropertiesToDelete(SoilAmendmentTaskProductsModel), + ), + purpose_relationships: taskProduct.purpose_relationships + ? taskProduct.purpose_relationships.map((relationship) => { + return { + ..._omit( + relationship, + getPropertiesToDelete( + soilAmendmentTaskProductPurposeRelationshipModel, + ), + ), + }; + }) + : null, + }; + }) + : null, field_work_task: managementTask.task.field_work_task ? { ..._omit( diff --git a/packages/api/src/util/errorCodes.js b/packages/api/src/util/errorCodes.js new file mode 100644 index 0000000000..a58276b3b8 --- /dev/null +++ b/packages/api/src/util/errorCodes.js @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024 Free Software Foundation, Inc. + * This file (baseController.js) 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 . + */ + +// See https://github.com/Vincit/objection.js/issues/2023#issuecomment-806059039 +import objection from 'objection'; + +/** + * Handles objection.js errors and sends appropriate responses based on the error type. + * Augmented from: https://vincit.github.io/objection.js/recipes/error-handling.html#examples + * + * @param {Error} err - The objection.js error object. + * @param {import('express').Response} res - The Express response object. + * @param {import('objection').Transaction} trx - The objection.js transaction object. + * @returns {Promise} A promise that resolves after handling the error and sending the response. + * + * @example + * try { + * // Some objection.js operation that may throw an error + * } catch (error) { + * await handleObjectionError(error, res, trx); + * } + */ +export async function handleObjectionError(err, res, trx) { + console.error(err); // also reports to Sentry + if (err instanceof objection.ValidationError) { + switch (err.type) { + case 'ModelValidation': + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: err.type, + data: err.data, + }); + break; + case 'RelationExpression': + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'RelationExpression', + data: {}, + }); + break; + case 'UnallowedRelation': + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: err.type, + data: {}, + }); + break; + case 'InvalidGraph': + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: err.type, + data: {}, + }); + break; + default: + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'UnknownValidationError', + data: {}, + }); + break; + } + } else if (err instanceof objection.NotFoundError) { + await trx.rollback(); + res.status(404).send({ + message: err.message, + type: 'NotFound', + data: {}, + }); + } else if (err instanceof objection.UniqueViolationError) { + await trx.rollback(); + res.status(409).send({ + message: err.message, + type: 'UniqueViolation', + data: { + columns: err.columns, + table: err.table, + constraint: err.constraint, + }, + }); + } else if (err instanceof objection.NotNullViolationError) { + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'NotNullViolation', + data: { + column: err.column, + table: err.table, + }, + }); + } else if (err instanceof objection.ForeignKeyViolationError) { + await trx.rollback(); + res.status(409).send({ + message: err.message, + type: 'ForeignKeyViolation', + data: { + table: err.table, + constraint: err.constraint, + }, + }); + } else if (err instanceof objection.CheckViolationError) { + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'CheckViolation', + data: { + table: err.table, + constraint: err.constraint, + }, + }); + } else if (err instanceof objection.ConstraintViolationError) { + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'ConstraintViolation', + data: {}, + }); + } else if (err instanceof objection.DataError) { + await trx.rollback(); + res.status(400).send({ + message: err.message, + type: 'InvalidData', + data: {}, + }); + } else if (err instanceof objection.DBError) { + await trx.rollback(); + res.status(500).send({ + message: err.message, + type: 'UnknownDatabaseError', + data: {}, + }); + } else { + await trx.rollback(); + res.status(500).send({ + message: err.message, + type: 'UnknownError', + data: {}, + }); + } +} diff --git a/packages/api/src/util/util.js b/packages/api/src/util/util.js index 494e8796f0..aac384222a 100644 --- a/packages/api/src/util/util.js +++ b/packages/api/src/util/util.js @@ -40,3 +40,17 @@ export const getSortedDates = (dates) => { const jsDates = dates.map((date) => new Date(date)); return jsDates.sort((date1, date2) => date1 - date2); }; + +/** + * Checks if the input is a string and not just white space. + * Returns a trimmed version of the input if it's a valid string, or null + * + * @param {string} input - The input string to validate and trim. + * @return {string | null} - The trimmed string if valid, otherwise null. + */ +export const checkAndTrimString = (input) => { + if (typeof input !== 'string' || !input.trim()) { + return null; + } + return input.trim(); +}; diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 74b937b5cc..40ef471497 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -1131,33 +1131,97 @@ function fakeProduct(defaultData = {}) { name: faker.lorem.words(2), supplier: faker.lorem.words(3), on_permitted_substances_list: faker.helpers.arrayElement(['YES', 'NO', 'NOT_SURE']), - type: faker.helpers.arrayElement(['soil_amendment_task', 'pest_control_task', 'cleaning_task']), + // For soil_amendment_task use soil_amendment_productFactory + type: faker.helpers.arrayElement(['pest_control_task', 'cleaning_task']), ...defaultData, }; } +async function soil_amendment_productFactory({ promisedProduct = productFactory() } = {}) { + const [{ product_id, type }] = await promisedProduct; + const productDetails = fakeProductDetails(type); + return knex('soil_amendment_product') + .insert({ product_id, ...productDetails }) + .returning('*'); +} + +function fakeProductDetails(type, defaultData = {}) { + if (type === 'soil_amendment_task') { + return { + ...defaultData, + }; + } + + return {}; +} + +async function soil_amendment_methodFactory() { + return knex('soil_amendment_method').insert({ key: faker.lorem.word() }).returning('*'); +} + +async function soil_amendment_purposeFactory() { + return knex('soil_amendment_purpose').insert({ key: 'OTHER' }).returning('*'); +} + +async function soil_amendment_fertiliser_typeFactory() { + return knex('soil_amendment_fertiliser_type').insert({ key: faker.lorem.word() }).returning('*'); +} + async function soil_amendment_taskFactory( - { promisedTask = taskFactory(), promisedProduct = productFactory() } = {}, + { promisedTask = taskFactory(), promisedMethod = soil_amendment_methodFactory() } = {}, soil_amendment_task = fakeSoilAmendmentTask(), ) { - const [task, product] = await Promise.all([promisedTask, promisedProduct]); + const [task, method] = await Promise.all([promisedTask, promisedMethod]); const [{ task_id }] = task; - const [{ product_id }] = product; + const [{ method_id }] = method; return knex('soil_amendment_task') - .insert({ task_id, product_id, ...soil_amendment_task }) + .insert({ + task_id, + method_id, + ...soil_amendment_task, + }) .returning('*'); } function fakeSoilAmendmentTask(defaultData = {}) { return { - product_quantity: faker.datatype.number(), - purpose: faker.helpers.arrayElement([ - 'structure', - 'moisture_retention', - 'nutrient_availability', - 'ph', - 'other', + ...defaultData, + }; +} + +async function soil_amendment_task_productsFactory( + { promisedTask = taskFactory() } = {}, + soil_amendment_task_product = fakeSoilAmendmentTaskProduct(), +) { + const [task] = await promisedTask; + return knex('soil_amendment_task_products') + .insert({ + task_id: task.task_id, + ...soil_amendment_task_product, + }) + .returning('*'); +} + +function fakeSoilAmendmentTaskProduct(defaultData = {}) { + return { + weight: faker.datatype.number(), + weight_unit: faker.helpers.arrayElement(['lb', 'kg']), + application_rate_weight_unit: faker.helpers.arrayElement([ + 'g/m2', + 'lb/ft2', + 'kg/m2', + 't/ft2', + 'mt/m2', + 'oz/ft2', + 'g/ha', + 'lb/ac', + 'kg/ha', + 't/ac', + 'mt/ha', + 'oz/ac', ]), + percent_of_location_amended: faker.datatype.number({ min: 1, max: 100 }), + total_area_amended: faker.datatype.number({ min: 1, max: 1000 }), ...defaultData, }; } @@ -1447,6 +1511,9 @@ function fakeDisease(defaultData = {}) { }; } +const volumeUnits = ['l', 'gal', 'ml', 'fl-oz']; +const weightUnits = ['g', 'lb', 'kg', 't', 'mt', 'oz']; + async function pest_control_taskFactory( { promisedTask = taskFactory(), promisedProduct = productFactory() } = {}, pestTask = fakePestControlTask(), @@ -1454,11 +1521,26 @@ async function pest_control_taskFactory( const [task, product] = await Promise.all([promisedTask, promisedProduct]); const [{ task_id }] = task; const [{ product_id }] = product; + + // The model normally handles this conversion, but this is a direct insert into the database + const isVolume = volumeUnits.includes(pestTask.product_quantity_unit); + const isWeight = weightUnits.includes(pestTask.product_quantity_unit); + + const updatedPestTask = { + ...pestTask, + volume: isVolume ? pestTask.product_quantity : null, + volume_unit: isVolume ? pestTask.product_quantity_unit : null, + weight: isWeight ? pestTask.product_quantity : null, + weight_unit: isWeight ? pestTask.product_quantity_unit : null, + }; + delete updatedPestTask.product_quantity; + delete updatedPestTask.product_quantity_unit; + return knex('pest_control_task') .insert({ task_id, product_id, - ...pestTask, + ...updatedPestTask, }) .returning('*'); } @@ -1466,6 +1548,7 @@ async function pest_control_taskFactory( function fakePestControlTask(defaultData = {}) { return { product_quantity: faker.datatype.number(2000), + product_quantity_unit: faker.helpers.arrayElement([...volumeUnits, ...weightUnits]), pest_target: faker.lorem.words(2), control_method: faker.helpers.arrayElement([ 'systemicSpray', @@ -2197,7 +2280,13 @@ export default { fakeHarvestUse, productFactory, fakeProduct, + fakeProductDetails, + soil_amendment_productFactory, + soil_amendment_methodFactory, + soil_amendment_purposeFactory, + soil_amendment_fertiliser_typeFactory, soil_amendment_taskFactory, + soil_amendment_task_productsFactory, fakeSoilAmendmentTask, pesticideFactory, fakePesticide, @@ -2216,6 +2305,7 @@ export default { fakeFieldWorkTask, soil_taskFactory, fakeSoilTask, + fakeSoilAmendmentTaskProduct, irrigation_taskFactory, fakeIrrigationTask, scouting_taskFactory, diff --git a/packages/api/tests/organicCertifierSurvey.test.js b/packages/api/tests/organicCertifierSurvey.test.js index 31515d7c66..2fb012a678 100644 --- a/packages/api/tests/organicCertifierSurvey.test.js +++ b/packages/api/tests/organicCertifierSurvey.test.js @@ -182,8 +182,13 @@ describe('organic certification Tests', () => { describe('Get all supported certifications', () => { test('User should get all supported certifications', async (done) => { getAllSupportedCertificationsRequest({}, (err, res) => { + const thirdPartyOrganic = res.body.find( + (cert) => cert.certification_translation_key === 'THIRD_PARTY_ORGANIC', + ); + const pgs = res.body.find((cert) => cert.certification_translation_key === 'PGS'); expect(res.status).toBe(200); - expect(res.body[0].certification_type).toBe('Organic'); + expect(thirdPartyOrganic.certification_type).toBe('Third-party Organic'); + expect(pgs.certification_type).toBe('Participatory Guarantee System'); done(); }); }); diff --git a/packages/api/tests/product.test.js b/packages/api/tests/product.test.js index ec166c9f56..c9f6b8f5c0 100644 --- a/packages/api/tests/product.test.js +++ b/packages/api/tests/product.test.js @@ -31,6 +31,7 @@ jest.mock('../src/middleware/acl/checkJwt.js', () => ); import mocks from './mock.factories.js'; import productModel from '../src/models/productModel.js'; +import soilAmendmentProductModel from '../src/models/soilAmendmentProductModel.js'; describe('Product Tests', () => { // let middleware; @@ -55,13 +56,41 @@ describe('Product Tests', () => { .end(callback); } + async function patchRequest(data, product_id, { user_id, farm_id }) { + return await chai + .request(server) + .patch(`/product/${product_id}`) + .set('Content-Type', 'application/json') + .set('user_id', user_id) + .set('farm_id', farm_id) + .send(data); + } + function fakeUserFarm(role = 1) { return { ...mocks.fakeUserFarm(), role_id: role }; } - function getFakeFertilizer(farm_id = farm.farm_id) { - const fertilizer = mocks.fakeFertilizer(); - return { ...fertilizer, farm_id }; + async function createProductInDatabase(mainFarm, properties) { + if (properties?.type === 'soil_amendment_task') { + const [product] = await mocks.productFactory( + { + promisedFarm: [mainFarm], + }, + properties, + ); + const [soilAmendmentProduct] = await mocks.soil_amendment_productFactory({ + promisedProduct: [product], + }); + return soilAmendmentProduct; + } else { + const [product] = await mocks.productFactory( + { + promisedFarm: [mainFarm], + }, + properties, + ); + return product; + } } afterAll(async (done) => { @@ -166,9 +195,10 @@ describe('Product Tests', () => { }); }); - describe('Post fertilizer authorization tests', () => { - test('Owner should post and get product', async (done) => { - const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + test('All users should be able to post and get a product', async (done) => { + const allUserRoles = [1, 2, 3, 5]; + for (const role of allUserRoles) { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm(role)); prod.farm_id = userFarm.farm_id; postProductRequest(prod, userFarm, async (err, res) => { expect(res.status).toBe(201); @@ -179,33 +209,239 @@ describe('Product Tests', () => { expect(productsSaved.length).toBe(1); done(); }); + } + }); + + test('should return 400 if elemental value is provided without elemental_unit', async (done) => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const npkProduct = mocks.fakeProduct({ + farm_id: userFarm.farm_id, + soil_amendment_product: { + n: 70, + p: 30, + k: 20, + }, }); - test('Manager should post and get a product', async (done) => { - const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm(2)); - prod.farm_id = userFarm.farm_id; - postProductRequest(prod, userFarm, async (err, res) => { - expect(res.status).toBe(201); - const products = await productModel - .query() - .context({ showHidden: true }) - .where('farm_id', userFarm.farm_id); - expect(products.length).toBe(1); - done(); - }); + postProductRequest(npkProduct, userFarm, (err, res) => { + expect(res.status).toBe(400); + done(); }); + }); - test('should return 403 status if product is posted by worker', async (done) => { - const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm(3)); - prod.farm_id = userFarm.farm_id; - postProductRequest(prod, userFarm, async (err, res) => { - expect(res.status).toBe(403); - expect(res.error.text).toBe( - 'User does not have the following permission(s): add:product', - ); + test('should return 400 if elemental_unit is percent and n + p + k > 100', async (done) => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const npkProduct = mocks.fakeProduct({ + farm_id: userFarm.farm_id, + soil_amendment_product: { + n: 70, + p: 30, + k: 20, + elemental_unit: 'percent', + }, + }); + + postProductRequest(npkProduct, userFarm, (err, res) => { + expect(res.status).toBe(400); + done(); + }); + }); + + test('should return 409 conflict if a product is created with the same name as an existing product', async (done) => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const fertiliserProductA = mocks.fakeProduct({ + name: 'Fertiliser Product A', + type: 'soil_amendment_task', + }); + + const soilAmendmentProductDetails = { + soil_amendment_product: mocks.fakeProductDetails('soil_amendment_task'), + }; + + await createProductInDatabase(userFarm, fertiliserProductA); + + postProductRequest( + { ...fertiliserProductA, ...soilAmendmentProductDetails }, + userFarm, + (err, res) => { + expect(res.status).toBe(409); done(); - }); + }, + ); + }); + + test('should successfully populate soil_amendment_product table', async (done) => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const soilAmendmentProduct = mocks.fakeProduct({ + farm_id: userFarm.farm_id, + type: 'soil_amendment_task', + soil_amendment_product: { + n: 1, + p: 2, + k: 1, + elemental_unit: 'ratio', + }, }); + + postProductRequest(soilAmendmentProduct, userFarm, async (err, res) => { + expect(res.status).toBe(201); + + const [productRecord] = await productModel + .query() + .context({ showHidden: true }) + .where('farm_id', userFarm.farm_id); + + const [soilAmendmentProductRecord] = await soilAmendmentProductModel + .query() + .where({ product_id: productRecord.product_id }); + + expect(soilAmendmentProductRecord.n).toBe(1); + expect(soilAmendmentProductRecord.p).toBe(2); + expect(soilAmendmentProductRecord.k).toBe(1); + expect(soilAmendmentProductRecord.elemental_unit).toBe('ratio'); + + done(); + }); + }); + }); + + describe('Update product', () => { + test('All users should be able to patch product', async () => { + const allUserRoles = [1, 2, 3, 5]; + + for (const role of allUserRoles) { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm(role)); + + const origProduct = await createProductInDatabase(userFarm); + + const res = await patchRequest( + { + supplier: 'UBC Botanical Garden', + }, + origProduct.product_id, + userFarm, + ); + + expect(res.status).toBe(204); + + const [updatedProduct] = await productModel + .query() + .where({ product_id: origProduct.product_id }); + + expect(updatedProduct.supplier).toBe('UBC Botanical Garden'); + } + }); + + test('should return 400 if n, p, or k value is patched without elemental_unit', async () => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const origProduct = await createProductInDatabase(userFarm); + + const res = await patchRequest( + { + soil_amendment_product: { + n: 70, + p: 30, + k: 30, + }, + }, + origProduct.product_id, + userFarm, + ); + expect(res.status).toBe(400); + }); + + test('should return 400 if patched elemental_unit is percent and patched n + p + k > 100', async () => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const origProduct = await createProductInDatabase(userFarm); + + const res = await patchRequest( + { + soil_amendment_product: { + n: 70, + p: 30, + k: 30, + elemental_unit: 'percent', + }, + }, + origProduct.product_id, + userFarm, + ); + expect(res.status).toBe(400); + }); + + test('should return 409 conflict if a product is patched to a name that conflicts with an existing product', async () => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const fertiliserProductA = mocks.fakeProduct({ + name: 'Fertiliser Product A', + type: 'soil_amendment_task', + }); + + await createProductInDatabase(userFarm, fertiliserProductA); + + const soilAmendmentProductDetailsA = { + soil_amendment_product: mocks.fakeProductDetails('soil_amendment_task'), + }; + + const fertiliserProductB = mocks.fakeProduct({ + name: 'Fertiliser Product B', + type: 'soil_amendment_task', + }); + + const soilAmendmentProductDetailsB = { + soil_amendment_product: mocks.fakeProductDetails('soil_amendment_task'), + }; + + const origProduct = await createProductInDatabase(userFarm, fertiliserProductB); + + const res = await patchRequest( + { + name: 'Fertiliser Product A', + }, + origProduct.product_id, + userFarm, + ); + expect(res.status).toBe(409); + }); + + test('should successfully patch soil_amendment_product table values', async () => { + const [userFarm] = await mocks.userFarmFactory({}, fakeUserFarm()); + + const fertiliserProduct = mocks.fakeProduct({ + name: 'Fertiliser Product', + type: 'soil_amendment_task', + }); + + // Note: this is a direct knex insert (not via model) so creating a record in the soil_amendment_product table cannot be done like this + const origProduct = await createProductInDatabase(userFarm, fertiliserProduct); + + const res = await patchRequest( + { + soil_amendment_product: { + ammonium: 78, + nitrate: 112, + molecular_compounds_unit: 'ppm', + }, + }, + origProduct.product_id, + userFarm, + ); + + expect(res.status).toBe(204); + + const [updatedSoilAmendmentProduct] = await soilAmendmentProductModel + .query() + .where({ product_id: origProduct.product_id }); + + expect(updatedSoilAmendmentProduct.ammonium).toBe(78); + expect(updatedSoilAmendmentProduct.nitrate).toBe(112); + expect(updatedSoilAmendmentProduct.molecular_compounds_unit).toBe('ppm'); }); }); }); diff --git a/packages/api/tests/soil_amendment_fertiliser_types.test.js b/packages/api/tests/soil_amendment_fertiliser_types.test.js new file mode 100644 index 0000000000..29512988c5 --- /dev/null +++ b/packages/api/tests/soil_amendment_fertiliser_types.test.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 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 chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import server from './../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); +import mocks from './mock.factories.js'; + +describe('Soil Amendment FertiliserType Test', () => { + let token; + + beforeAll(() => { + token = global.token; + }); + + afterEach(async (done) => { + await tableCleanup(knex); + done(); + }); + + afterAll(async (done) => { + await knex.destroy(); + done(); + }); + + async function getRequest({ user_id, farm_id }) { + return await chai + .request(server) + .get('/soil_amendment_fertiliser_types') + .set('user_id', user_id) + .set('farm_id', farm_id); + } + + function fakeUserFarm(role = 1) { + return { ...mocks.fakeUserFarm(), role_id: role }; + } + + async function returnUserFarms(role) { + const [mainFarm] = await mocks.farmFactory(); + const [user] = await mocks.usersFactory(); + + await mocks.userFarmFactory( + { + promisedUser: [user], + promisedFarm: [mainFarm], + }, + fakeUserFarm(role), + ); + return { mainFarm, user }; + } + + async function makeSoilAmendmentFertiliserType() { + const [soil_amendment_fertiliser_type] = await mocks.soil_amendment_fertiliser_typeFactory(); + return soil_amendment_fertiliser_type; + } + + // GET TESTS + describe('GET soil amendment fertiliser type tests', () => { + test('All farm users should get soil amendment fertiliser type', async () => { + const roles = [1, 2, 3, 5]; + + await makeSoilAmendmentFertiliserType(); + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const res = await getRequest({ user_id: user.user_id, farm_id: mainFarm.farm_id }); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + } + }); + + test('Unauthorized user should also get soil amendment fertiliser type', async () => { + const { mainFarm } = await returnUserFarms(1); + + await makeSoilAmendmentFertiliserType(); + + const [unAuthorizedUser] = await mocks.usersFactory(); + + const res = await getRequest({ + user_id: unAuthorizedUser.user_id, + farm_id: mainFarm.farm_id, + }); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + }); + }); +}); diff --git a/packages/api/tests/soil_amendment_methods.test.js b/packages/api/tests/soil_amendment_methods.test.js new file mode 100644 index 0000000000..801ca981ad --- /dev/null +++ b/packages/api/tests/soil_amendment_methods.test.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 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 chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import server from './../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); +import mocks from './mock.factories.js'; + +describe('Soil Amendment Methods Test', () => { + let token; + + beforeAll(() => { + token = global.token; + }); + + afterEach(async (done) => { + await tableCleanup(knex); + done(); + }); + + afterAll(async (done) => { + await knex.destroy(); + done(); + }); + + async function getRequest({ user_id, farm_id }) { + return await chai + .request(server) + .get('/soil_amendment_methods') + .set('user_id', user_id) + .set('farm_id', farm_id); + } + + function fakeUserFarm(role = 1) { + return { ...mocks.fakeUserFarm(), role_id: role }; + } + + async function returnUserFarms(role) { + const [mainFarm] = await mocks.farmFactory(); + const [user] = await mocks.usersFactory(); + + await mocks.userFarmFactory( + { + promisedUser: [user], + promisedFarm: [mainFarm], + }, + fakeUserFarm(role), + ); + return { mainFarm, user }; + } + + async function makeSoilAmendmentMethod() { + const [soil_amendment_method] = await mocks.soil_amendment_methodFactory(); + return soil_amendment_method; + } + + // GET TESTS + describe('GET soil amendment methods tests', () => { + test('All farm users should get soil amendment methods', async () => { + const roles = [1, 2, 3, 5]; + + await makeSoilAmendmentMethod(); + + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const res = await getRequest({ user_id: user.user_id, farm_id: mainFarm.farm_id }); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + } + }); + + test('Unauthorized user should also get soil amendment methods', async () => { + const { mainFarm } = await returnUserFarms(1); + + await makeSoilAmendmentMethod(); + + const [unAuthorizedUser] = await mocks.usersFactory(); + + const res = await getRequest({ + user_id: unAuthorizedUser.user_id, + farm_id: mainFarm.farm_id, + }); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + }); + }); +}); diff --git a/packages/api/tests/soil_amendment_purposes.test.js b/packages/api/tests/soil_amendment_purposes.test.js new file mode 100644 index 0000000000..756ec3a78f --- /dev/null +++ b/packages/api/tests/soil_amendment_purposes.test.js @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2024 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 chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import server from './../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); +import mocks from './mock.factories.js'; + +describe('Soil Amendment Purpose Test', () => { + let token; + + beforeAll(() => { + token = global.token; + }); + + afterEach(async (done) => { + await tableCleanup(knex); + done(); + }); + + afterAll(async (done) => { + await knex.destroy(); + done(); + }); + + async function getRequest({ user_id, farm_id }) { + return await chai + .request(server) + .get('/soil_amendment_purposes') + .set('user_id', user_id) + .set('farm_id', farm_id); + } + + function fakeUserFarm(role = 1) { + return { ...mocks.fakeUserFarm(), role_id: role }; + } + + async function returnUserFarms(role) { + const [mainFarm] = await mocks.farmFactory(); + const [user] = await mocks.usersFactory(); + + await mocks.userFarmFactory( + { + promisedUser: [user], + promisedFarm: [mainFarm], + }, + fakeUserFarm(role), + ); + return { mainFarm, user }; + } + + async function makeSoilAmendmentPurpose() { + const [soil_amendment_purpose] = await mocks.soil_amendment_purposeFactory(); + return soil_amendment_purpose; + } + + // GET TESTS + describe('GET soil amendment purpose tests', () => { + test('All farm users should get soil amendment purpose', async () => { + const roles = [1, 2, 3, 5]; + await makeSoilAmendmentPurpose(); + for (const role of roles) { + const { mainFarm, user } = await returnUserFarms(role); + + const res = await getRequest({ user_id: user.user_id, farm_id: mainFarm.farm_id }); + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + } + }); + + test('Unauthorized user should also get soil amendment purpose', async () => { + const { mainFarm } = await returnUserFarms(1); + + await makeSoilAmendmentPurpose(); + + const [unAuthorizedUser] = await mocks.usersFactory(); + + const res = await getRequest({ + user_id: unAuthorizedUser.user_id, + farm_id: mainFarm.farm_id, + }); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(1); + }); + }); +}); diff --git a/packages/api/tests/task.test.js b/packages/api/tests/task.test.js index 848374bbd3..9c5461360f 100644 --- a/packages/api/tests/task.test.js +++ b/packages/api/tests/task.test.js @@ -201,10 +201,21 @@ describe('Task tests', () => { return userFarms; }; + const tasksWithProducts = ['soil_amendment_task']; + async function getTask(task_id) { return knex('task').where({ task_id }).first(); } + beforeAll(async () => { + // Check in controller expects Soil Amendment Task to exist + await knex('task_type').insert({ + farm_id: null, + task_name: 'Soil amendment', + task_translation_key: 'SOIL_AMENDMENT_TASK', + }); + }); + afterAll(async (done) => { await tableCleanup(knex); await knex.destroy(); @@ -1016,16 +1027,44 @@ describe('Task tests', () => { describe('creating types of tasks', () => { let product; let productData; + let soilAmendmentMethod; + let soilAmendmentPurpose; beforeEach(async () => { [{ product_id: product }] = await mocks.productFactory( {}, mocks.fakeProduct({ supplier: 'mock' }), ); productData = mocks.fakeProduct({ supplier: 'test' }); + + [{ id: soilAmendmentMethod }] = await mocks.soil_amendment_methodFactory(); + + [{ id: soilAmendmentPurpose }] = await mocks.soil_amendment_purposeFactory(); }); + + const fakeProductData = { + soil_amendment_task_products: async (farm_id) => { + // checkSoilAmendmentTaskProducts middleware requires a product that belongs to the given farm + const [soilAmendmentProduct] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProduct], + }); + + return [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProduct.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ]; + }, + }; + const fakeTaskData = { - soil_amendment_task: () => - mocks.fakeSoilAmendmentTask({ product_id: product, product: productData }), + soil_amendment_task: () => mocks.fakeSoilAmendmentTask({ method_id: soilAmendmentMethod }), pest_control_task: () => mocks.fakePestControlTask({ product_id: product, product: productData }), irrigation_task: () => mocks.fakeIrrigationTask(), @@ -1251,9 +1290,10 @@ describe('Task tests', () => { planting_management_plan_id, task_type_id, } = await userFarmTaskGenerator(); + const data = { ...mocks.fakeTask({ - [type]: { ...fakeTaskData[type]() }, + [type]: fakeTaskData[type](), task_type_id, owner_user_id: user_id, }), @@ -1261,6 +1301,9 @@ describe('Task tests', () => { managementPlans: [{ planting_management_plan_id }], }; + if (tasksWithProducts.some((task) => task == type)) { + data[`${type}_products`] = await fakeProductData[`${type}_products`](farm_id); + } postTaskRequest({ user_id, farm_id }, type, data, async (err, res) => { expect(res.status).toBe(201); const { task_id } = res.body; @@ -1380,7 +1423,7 @@ describe('Task tests', () => { }); }); - test('should create a task (i.e soilamendment) with multiple management plans', async (done) => { + test('should create a task (i.e soil amendment) with multiple management plans', async (done) => { const { user_id, farm_id, location_id, task_type_id } = await userFarmTaskGenerator(true); const promisedManagement = await Promise.all( [...Array(3)].map(async () => @@ -1407,9 +1450,27 @@ describe('Task tests', () => { planting_management_plan_id, })); + const [farmProduct] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [farmProduct], + }); + const data = { ...mocks.fakeTask({ - soil_amendment_task: { ...fakeTaskData.soil_amendment_task() }, + soil_amendment_task: { + method_id: soilAmendmentMethod, + ...mocks.fakeSoilAmendmentTask(), + }, + soil_amendment_task_products: [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: farmProduct.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ], task_type_id, owner_user_id: user_id, }), @@ -1431,7 +1492,7 @@ describe('Task tests', () => { }); }); - test('should create a task (i.e soilamendment) and override wage', async (done) => { + test('should create a task (i.e soil amendment) and override wage', async (done) => { const { user_id, farm_id, location_id, task_type_id } = await userFarmTaskGenerator(true); const promisedManagement = await Promise.all( [...Array(3)].map(async () => @@ -1457,9 +1518,27 @@ describe('Task tests', () => { const managementPlans = plantingManagementPlans.map(({ planting_management_plan_id }) => ({ planting_management_plan_id, })); + const [farmProduct] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [farmProduct], + }); + const data = { ...mocks.fakeTask({ - soil_amendment_task: { ...fakeTaskData.soil_amendment_task() }, + soil_amendment_task: { + method_id: soilAmendmentMethod, + ...mocks.fakeSoilAmendmentTask(), + }, + soil_amendment_task_products: [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: farmProduct.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ], task_type_id, owner_user_id: user_id, wage_at_moment: 50, @@ -1483,7 +1562,7 @@ describe('Task tests', () => { }); }); - test('should create a task (i.e soilamendment) and patch a product', async (done) => { + test('should create a task (i.e pest control) and patch a product', async (done) => { const { user_id, farm_id, location_id, task_type_id } = await userFarmTaskGenerator(true); const promisedManagement = await Promise.all( [...Array(3)].map(async () => @@ -1509,13 +1588,13 @@ describe('Task tests', () => { const managementPlans = plantingManagementPlans.map(({ planting_management_plan_id }) => ({ planting_management_plan_id, })); - const soilAmendmentProduct = mocks.fakeProduct(); - soilAmendmentProduct.name = 'soilProduct'; + const pestControlProduct = mocks.fakeProduct(); + pestControlProduct.name = 'pestProduct'; const data = { ...mocks.fakeTask({ - soil_amendment_task: { - ...fakeTaskData.soil_amendment_task(), - product: soilAmendmentProduct, + pest_control_task: { + ...fakeTaskData.pest_control_task(), + product: pestControlProduct, }, task_type_id, owner_user_id: user_id, @@ -1525,10 +1604,10 @@ describe('Task tests', () => { managementPlans, }; - postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', data, async (err, res) => { + postTaskRequest({ user_id, farm_id }, 'pest_control_task', data, async (err, res) => { expect(res.status).toBe(201); const { task_id } = res.body; - const { product_id } = res.body.soil_amendment_task; + const { product_id } = res.body.pest_control_task; const createdTask = await knex('task').where({ task_id }).first(); expect(createdTask).toBeDefined(); const isTaskRelatedToLocation = await knex('location_tasks').where({ task_id }).first(); @@ -1538,12 +1617,12 @@ describe('Task tests', () => { const isTaskRelatedToManagementPlans = await knex('management_tasks').where({ task_id }); expect(isTaskRelatedToManagementPlans.length).toBe(3); const specificProduct = await knex('product').where({ product_id }).first(); - expect(specificProduct.name).toBe('soilProduct'); + expect(specificProduct.name).toBe('pestProduct'); done(); }); }); - test('should create a task (i.e soilamendment) and create a product', async (done) => { + test('should create a task (i.e pest control) and create a product', async (done) => { const { user_id, farm_id, location_id, task_type_id } = await userFarmTaskGenerator(true); const promisedManagement = await Promise.all( [...Array(3)].map(async () => @@ -1569,14 +1648,14 @@ describe('Task tests', () => { const managementPlans = plantingManagementPlans.map(({ planting_management_plan_id }) => ({ planting_management_plan_id, })); - const soilAmendmentProduct = mocks.fakeProduct(); - soilAmendmentProduct.name = 'soilProduct2'; - soilAmendmentProduct.farm_id = farm_id; + const pestControlProduct = mocks.fakeProduct(); + pestControlProduct.name = 'pestProduct2'; + pestControlProduct.farm_id = farm_id; const data = { ...mocks.fakeTask({ - soil_amendment_task: { - ...fakeTaskData.soil_amendment_task(), - product: soilAmendmentProduct, + pest_control_task: { + ...fakeTaskData.pest_control_task(), + product: pestControlProduct, product_id: null, }, task_type_id, @@ -1587,10 +1666,10 @@ describe('Task tests', () => { managementPlans, }; - postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', data, async (err, res) => { + postTaskRequest({ user_id, farm_id }, 'pest_control_task', data, async (err, res) => { expect(res.status).toBe(201); const { task_id } = res.body; - const { product_id } = res.body.soil_amendment_task; + const { product_id } = res.body.pest_control_task; const createdTask = await knex('task').where({ task_id }).first(); expect(createdTask).toBeDefined(); const isTaskRelatedToLocation = await knex('location_tasks').where({ task_id }).first(); @@ -1600,7 +1679,7 @@ describe('Task tests', () => { const isTaskRelatedToManagementPlans = await knex('management_tasks').where({ task_id }); expect(isTaskRelatedToManagementPlans.length).toBe(3); const specificProduct = await knex('product').where({ product_id }).first(); - expect(specificProduct.name).toBe('soilProduct2'); + expect(specificProduct.name).toBe('pestProduct2'); done(); }); }); @@ -1681,17 +1760,43 @@ describe('Task tests', () => { describe('Patch tasks completion tests', () => { let product; let productData; + let soilAmendmentPurpose; + let soilAmendmentMethod; beforeEach(async () => { [{ product_id: product }] = await mocks.productFactory( {}, mocks.fakeProduct({ supplier: 'mock' }), ); productData = mocks.fakeProduct({ supplier: 'test' }); + [{ id: soilAmendmentMethod }] = await mocks.soil_amendment_methodFactory(); + [{ id: soilAmendmentPurpose }] = await mocks.soil_amendment_purposeFactory(); }); + const fakeProductData = { + soil_amendment_task_products: async (farm_id) => { + // checkSoilAmendmentTaskProducts middleware requires a product that belongs to the given farm + const [soilAmendmentProduct] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProduct], + }); + + return [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProduct.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ]; + }, + }; + const fakeTaskData = { - soil_amendment_task: () => - mocks.fakeSoilAmendmentTask({ product_id: product, product: productData }), + soil_amendment_task: () => mocks.fakeSoilAmendmentTask({ method_id: soilAmendmentMethod }), + pest_control_task: () => mocks.fakePestControlTask({ product_id: product, product: productData }), irrigation_task: () => mocks.fakeIrrigationTask(), @@ -1731,11 +1836,11 @@ describe('Task tests', () => { { user_id: another_id, farm_id }, { ...fakeCompletionData, - soil_amendment_task: fakeTaskData.soil_amendment_task(), + pest_control_task: fakeTaskData.pest_control_task(), assignee_user_id: user_id, }, task_id, - 'soil_amendment_task', + 'pest_control_task', async (err, res) => { expect(res.status).toBe(403); done(); @@ -1768,13 +1873,18 @@ describe('Task tests', () => { }); await mocks.soil_amendment_taskFactory({ promisedTask: [{ task_id }] }); - const new_soil_amendment_task = fakeTaskData.soil_amendment_task(); + const new_soil_amendment_task = fakeTaskData.soil_amendment_task(farm_id); + const new_soil_amendment_task_products = await fakeProductData.soil_amendment_task_products( + farm_id, + ); completeTaskRequest( { user_id, farm_id }, { ...fakeCompletionData, + task_id, soil_amendment_task: { task_id, ...new_soil_amendment_task }, + soil_amendment_task_products: new_soil_amendment_task_products, }, task_id, 'soil_amendment_task', @@ -1788,15 +1898,506 @@ describe('Task tests', () => { const patched_soil_amendment_task = await knex('soil_amendment_task') .where({ task_id }) .first(); - expect(patched_soil_amendment_task.product_quantity).toBe( - new_soil_amendment_task.product_quantity, - ); expect(patched_soil_amendment_task.purpose).toBe(new_soil_amendment_task.purpose); done(); }, ); }); + test('should be able to update a soil amendment task', async (done) => { + const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); + const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); + const [{ location_id }] = await mocks.locationFactory({ promisedFarm: [{ farm_id }] }); + + // Insert a second available purpose + const [{ id: soilAmendmentPurposeTwo }] = await mocks.soil_amendment_purposeFactory(); + + // Insert three differents products + const [soilAmendmentProductOne] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductOne], + }); + const [soilAmendmentProductTwo] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductTwo], + }); + const [soilAmendmentProductThree] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductThree], + }); + + // Initial task product state + const soilAmendmentTaskProductData = [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [ + { purpose_id: soilAmendmentPurpose }, + { purpose_id: soilAmendmentPurposeTwo }, + ], + }), + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductTwo.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ]; + + const taskData = { + ...mocks.fakeTask({ + soil_amendment_task: mocks.fakeSoilAmendmentTask({ + method_id: soilAmendmentMethod, + }), + soil_amendment_task_products: soilAmendmentTaskProductData, + task_type_id, + owner_user_id: user_id, + wage_at_moment: 50, + assignee_user_id: user_id, + }), + locations: [{ location_id }], + }; + + // Add task + postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', taskData, async (err, res) => { + expect(res.status).toBe(201); + const createdTask = res.body; + const createdTaskProducts = createdTask.soil_amendment_task_products; + const { task_id } = createdTask; + // Delete abandonment reason to prevent validation error + delete createdTask.abandonment_reason; + + // Remove a purpose relationship + const taskProductIdForDeletedPurpose = createdTaskProducts[0].id; + const deletedPurposeRelationship = createdTaskProducts[0].purpose_relationships.pop(); + // Delete a task product + const deletedTaskProduct = createdTaskProducts.pop(); + // Add a new task product + createdTaskProducts.push( + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductThree.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ); + + // Update the task + completeTaskRequest( + { user_id, farm_id }, + { + ...createdTask, + ...fakeCompletionData, + }, + task_id, + 'soil_amendment_task', + async (err, res) => { + expect(res.status).toBe(200); + + // Two active and one deleted task product should be present + const completed_task_products = await knex('soil_amendment_task_products').where({ + task_id, + }); + expect(completed_task_products.length).toBe(3); + expect( + completed_task_products.find((prod) => prod.id == deletedTaskProduct.id).deleted, + ).toBe(true); + const completed_soil_amendment_task_products_purpose_relationship = await knex( + 'soil_amendment_task_products_purpose_relationship', + ).whereIn('task_products_id', [ + completed_task_products[0].id, + completed_task_products[1].id, + completed_task_products[2].id, + ]); + expect(completed_soil_amendment_task_products_purpose_relationship.length).toBe(3); + + // The relationship created originally should be hard deleted + const deletedRelationship = await knex( + 'soil_amendment_task_products_purpose_relationship', + ).where('task_products_id', taskProductIdForDeletedPurpose); + expect(deletedRelationship.length).toBe(1); + expect(deletedRelationship[0].purpose_id).not.toBe( + deletedPurposeRelationship.purpose_id, + ); + + // The added product should be present and have a relationship + const addedTaskProduct = await knex('soil_amendment_task_products') + .where({ task_id }) + .andWhere({ product_id: soilAmendmentProductThree.product_id }); + expect(addedTaskProduct.length).toBe(1); + const addedRelationship = await knex( + 'soil_amendment_task_products_purpose_relationship', + ).where('task_products_id', addedTaskProduct[0].id); + expect(addedRelationship.length).toBe(1); + done(); + }, + ); + }); + }); + + test('should not be able to delete the last purpose', async (done) => { + const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); + const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); + const [{ location_id }] = await mocks.locationFactory({ promisedFarm: [{ farm_id }] }); + + // Insert a second available purpose + const [{ id: soilAmendmentPurposeTwo }] = await mocks.soil_amendment_purposeFactory(); + + // Insert two differents products + const [soilAmendmentProductOne] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductOne], + }); + const [soilAmendmentProductTwo] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductTwo], + }); + + // Initial task product state + const soilAmendmentTaskProductData = [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [ + { purpose_id: soilAmendmentPurpose }, + { purpose_id: soilAmendmentPurposeTwo }, + ], + }), + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductTwo.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ]; + + const taskData = { + ...mocks.fakeTask({ + soil_amendment_task: mocks.fakeSoilAmendmentTask({ + method_id: soilAmendmentMethod, + }), + soil_amendment_task_products: soilAmendmentTaskProductData, + task_type_id, + owner_user_id: user_id, + wage_at_moment: 50, + assignee_user_id: user_id, + }), + locations: [{ location_id }], + }; + + // Add task + postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', taskData, async (err, res) => { + expect(res.status).toBe(201); + const createdTask = res.body; + const createdTaskProducts = createdTask.soil_amendment_task_products; + const { task_id } = createdTask; + // Delete abandonment reason to prevent validation error + delete createdTask.abandonment_reason; + + // Remove all purpose relationships + const taskProductForDeletedPurpose = createdTaskProducts.find( + (product) => product.product_id == soilAmendmentProductTwo.product_id, + ); + const taskProductIdForDeletedPurpose = taskProductForDeletedPurpose.id; + taskProductForDeletedPurpose.purpose_relationships.pop(); + + // Update the task + completeTaskRequest( + { user_id, farm_id }, + { + ...createdTask, + ...fakeCompletionData, + }, + task_id, + 'soil_amendment_task', + async (err, res) => { + expect(res.status).toBe(400); + const completed_soil_amendment_task_products_purpose_relationship = await knex( + 'soil_amendment_task_products_purpose_relationship', + ).whereIn('task_products_id', [taskProductIdForDeletedPurpose]); + expect(completed_soil_amendment_task_products_purpose_relationship.length).toBe(1); + done(); + }, + ); + }); + }); + + test('should not be able to delete the last product', async (done) => { + const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); + const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); + const [{ location_id }] = await mocks.locationFactory({ promisedFarm: [{ farm_id }] }); + + // Insert a second available purpose + const [{ id: soilAmendmentPurposeTwo }] = await mocks.soil_amendment_purposeFactory(); + + // Insert one product + const [soilAmendmentProductOne] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + + // Initial task product state + const soilAmendmentTaskProductData = [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [ + { purpose_id: soilAmendmentPurpose }, + { purpose_id: soilAmendmentPurposeTwo }, + ], + }), + ]; + + const taskData = { + ...mocks.fakeTask({ + soil_amendment_task: mocks.fakeSoilAmendmentTask({ + method_id: soilAmendmentMethod, + }), + soil_amendment_task_products: soilAmendmentTaskProductData, + task_type_id, + owner_user_id: user_id, + wage_at_moment: 50, + assignee_user_id: user_id, + }), + locations: [{ location_id }], + }; + + // Add task + postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', taskData, async (err, res) => { + expect(res.status).toBe(201); + const createdTask = res.body; + const createdTaskProducts = createdTask.soil_amendment_task_products; + const { task_id } = createdTask; + // Delete abandonment reason to prevent validation error + delete createdTask.abandonment_reason; + + // Delete a task product + const deletedTaskProductOne = createdTaskProducts.pop(); + + completeTaskRequest( + { user_id, farm_id }, + { + ...createdTask, + ...fakeCompletionData, + }, + task_id, + 'soil_amendment_task', + async (err, res) => { + expect(res.status).toBe(400); + + // One deleted task product should be present + const completed_task_products = await knex('soil_amendment_task_products').where({ + task_id, + }); + expect(completed_task_products.length).toBe(1); + expect(completed_task_products[0].deleted).toBe(false); + done(); + }, + ); + }); + }); + + test('should not be able set deleted to true on the last product', async (done) => { + const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); + const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); + const [{ location_id }] = await mocks.locationFactory({ promisedFarm: [{ farm_id }] }); + + // Insert a second available purpose + const [{ id: soilAmendmentPurposeTwo }] = await mocks.soil_amendment_purposeFactory(); + + // Insert one product + const [soilAmendmentProductOne] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductOne], + }); + + // Initial task product state + const soilAmendmentTaskProductData = [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [ + { purpose_id: soilAmendmentPurpose }, + { purpose_id: soilAmendmentPurposeTwo }, + ], + }), + ]; + + const taskData = { + ...mocks.fakeTask({ + soil_amendment_task: mocks.fakeSoilAmendmentTask({ + method_id: soilAmendmentMethod, + }), + soil_amendment_task_products: soilAmendmentTaskProductData, + task_type_id, + owner_user_id: user_id, + wage_at_moment: 50, + assignee_user_id: user_id, + }), + locations: [{ location_id }], + }; + + // Add task + postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', taskData, async (err, res) => { + expect(res.status).toBe(201); + const createdTask = res.body; + const { task_id } = createdTask; + // Delete abandonment reason to prevent validation error + delete createdTask.abandonment_reason; + + // Delete a task product + createdTask.soil_amendment_task_products[0].deleted = true; + + completeTaskRequest( + { user_id, farm_id }, + { + ...createdTask, + ...fakeCompletionData, + }, + task_id, + 'soil_amendment_task', + async (err, res) => { + expect(res.status).toBe(400); + + // One deleted task product should be present + const completed_task_products = await knex('soil_amendment_task_products').where({ + task_id, + }); + expect(completed_task_products.length).toBe(1); + expect(completed_task_products[0].deleted).toBe(false); + done(); + }, + ); + }); + }); + + test('should be able to switch product ids on complete product by updating product id and adding removed product as new back in', async (done) => { + const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); + const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); + const [{ location_id }] = await mocks.locationFactory({ promisedFarm: [{ farm_id }] }); + + // Insert two products + const [soilAmendmentProductOne] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + const [soilAmendmentProductTwo] = await mocks.productFactory( + { promisedFarm: [{ farm_id }] }, + // of type 'soil_amendment_task' + mocks.fakeProduct({ type: 'soil_amendment_task' }), + ); + + // Make product details available + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductOne], + }); + await mocks.soil_amendment_productFactory({ + promisedProduct: [soilAmendmentProductTwo], + }); + + // Initial task product state + const soilAmendmentTaskProductData = [ + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductTwo.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }), + ]; + + const taskData = { + ...mocks.fakeTask({ + soil_amendment_task: mocks.fakeSoilAmendmentTask({ + method_id: soilAmendmentMethod, + }), + soil_amendment_task_products: soilAmendmentTaskProductData, + task_type_id, + owner_user_id: user_id, + wage_at_moment: 50, + assignee_user_id: user_id, + }), + locations: [{ location_id }], + }; + + // Add task + postTaskRequest({ user_id, farm_id }, 'soil_amendment_task', taskData, async (err, res) => { + expect(res.status).toBe(201); + const createdTask = res.body; + const { task_id } = createdTask; + // Delete abandonment reason to prevent validation error + delete createdTask.abandonment_reason; + + // Find index of second task product + const indexOfFirstProduct = createdTask.soil_amendment_task_products.findIndex( + (tp) => tp.product_id === soilAmendmentProductOne.product_id, + ); + const indexOfSecondProduct = createdTask.soil_amendment_task_products.findIndex( + (tp) => tp.product_id === soilAmendmentProductTwo.product_id, + ); + + // Replace second task product with new taskProduct with id of first task product + createdTask.soil_amendment_task_products[ + indexOfSecondProduct + ] = mocks.fakeSoilAmendmentTaskProduct({ + product_id: soilAmendmentProductOne.product_id, + purpose_relationships: [{ purpose_id: soilAmendmentPurpose }], + }); + + // Update first task product id to second product id + createdTask.soil_amendment_task_products[indexOfFirstProduct].product_id = + soilAmendmentProductTwo.product_id; + + completeTaskRequest( + { user_id, farm_id }, + { + ...createdTask, + ...fakeCompletionData, + }, + task_id, + 'soil_amendment_task', + async (err, res) => { + expect(res.status).toBe(200); + + // One deleted task product should be present + const completed_task_products = await knex('soil_amendment_task_products').where({ + task_id, + }); + expect(completed_task_products.length).toBe(3); + done(); + }, + ); + }); + }); + test('should be able to complete a pest control task', async (done) => { const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); @@ -1841,7 +2442,7 @@ describe('Task tests', () => { const patched_pest_control_task = await knex('pest_control_task') .where({ task_id }) .first(); - expect(patched_pest_control_task.product_quantity).toBe( + expect(patched_pest_control_task.volume || patched_pest_control_task.weight).toBe( new_pest_control_task.product_quantity, ); expect(patched_pest_control_task.pest_target).toBe(new_pest_control_task.pest_target); @@ -2079,7 +2680,7 @@ describe('Task tests', () => { }, ); }); - test('should complete a task (i.e soilamendment) with multiple management plans', async (done) => { + test('should complete a task (i.e pest control task) with multiple management plans', async (done) => { const userFarm = { ...fakeUserFarm(1), wage: { type: '', amount: 30 } }; const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, userFarm); const [{ task_type_id }] = await mocks.task_typeFactory({ promisedFarm: [{ farm_id }] }); @@ -2122,7 +2723,7 @@ describe('Task tests', () => { promisedTask: [{ task_id }], promisedField: [{ location_id }], }); - await mocks.soil_amendment_taskFactory({ promisedTask: [{ task_id }] }); + await mocks.pest_control_taskFactory({ promisedTask: [{ task_id }] }); await mocks.management_tasksFactory({ promisedTask: [{ task_id }], @@ -2137,16 +2738,16 @@ describe('Task tests', () => { promisedPlantingManagementPlan: [managementPlans[2]], }); - const new_soil_amendment_task = fakeTaskData.soil_amendment_task(); + const new_pest_control_task = fakeTaskData.pest_control_task(); completeTaskRequest( { user_id, farm_id }, { ...fakeCompletionData, - soil_amendment_task: { task_id, ...new_soil_amendment_task }, + pest_control_task: { task_id, ...new_pest_control_task }, }, task_id, - 'soil_amendment_task', + 'pest_control_task', async (err, res) => { expect(res.status).toBe(200); const completed_task = await knex('task').where({ task_id }).first(); @@ -2154,13 +2755,15 @@ describe('Task tests', () => { expect(completed_task.duration).toBe(duration); expect(completed_task.happiness).toBe(happiness); expect(completed_task.completion_notes).toBe(notes); - const patched_soil_amendment_task = await knex('soil_amendment_task') + const patched_pest_control_task = await knex('pest_control_task') .where({ task_id }) .first(); - expect(patched_soil_amendment_task.product_quantity).toBe( - new_soil_amendment_task.product_quantity, + expect(patched_pest_control_task.volume || patched_pest_control_task.weight).toBe( + new_pest_control_task.product_quantity, + ); + expect(patched_pest_control_task.control_method).toBe( + new_pest_control_task.control_method, ); - expect(patched_soil_amendment_task.purpose).toBe(new_soil_amendment_task.purpose); const management_plan_1 = await knex('management_plan') .where({ management_plan_id: promisedManagement[0][0].management_plan_id }) .first(); @@ -2186,6 +2789,7 @@ describe('Task tests', () => { abandonment_reason: CROP_FAILURE, other_abandonment_reason: null, abandonment_notes: sampleNote, + abandon_date: new Date(), }; test('An unassigned task should not abandoned with a rating', async (done) => { diff --git a/packages/api/tests/testEnvironment.js b/packages/api/tests/testEnvironment.js index 77dd8678a4..3921036b0f 100644 --- a/packages/api/tests/testEnvironment.js +++ b/packages/api/tests/testEnvironment.js @@ -65,6 +65,12 @@ async function tableCleanup(knex) { DELETE FROM "wash_and_pack_task"; DELETE FROM "cleaning_task"; DELETE FROM "soil_amendment_task"; + DELETE FROM "soil_amendment_task_products_purpose_relationship"; + DELETE FROM "soil_amendment_task_products"; + DELETE FROM "soil_amendment_purpose"; + DELETE FROM "soil_amendment_method"; + DELETE FROM "soil_amendment_product"; + DELETE FROM "soil_amendment_fertiliser_type"; DELETE FROM "product"; DELETE FROM "management_tasks"; DELETE FROM "plant_task"; diff --git a/packages/webapp/.storybook/preview.jsx b/packages/webapp/.storybook/preview.jsx index 3d60a09e0f..3211c1d267 100644 --- a/packages/webapp/.storybook/preview.jsx +++ b/packages/webapp/.storybook/preview.jsx @@ -1,7 +1,9 @@ -import React, { Suspense } from 'react'; +import React, { useEffect } from 'react'; import state from './state'; import { action } from '@storybook/addon-actions'; import theme from '../src/assets/theme'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../src/locales/i18n'; import { CssBaseline, ThemeProvider, StyledEngineProvider } from '@mui/material'; import { Provider } from 'react-redux'; import { useTranslation } from 'react-i18next'; @@ -26,7 +28,14 @@ const store = { dispatch: action('dispatch'), }; export const decorators = [ - (Story) => { + (Story, context) => { + // https://storybook.js.org/blog/internationalize-components-with-storybook/ + const { locale } = context.globals; + + useEffect(() => { + i18n.changeLanguage(locale); + }, [locale]); + const { t, ready } = useTranslation( [ 'certifications', @@ -54,10 +63,30 @@ export const decorators = [ - + + + ); }, ]; + +// https://storybook.js.org/blog/internationalize-components-with-storybook/ +export const globalTypes = { + locale: { + name: 'Locale', + description: 'Internationalization locale', + toolbar: { + icon: 'globe', + items: [ + { value: 'en', title: 'English' }, + { value: 'es', title: 'Spanish' }, + { value: 'fr', title: 'French' }, + { value: 'pt', title: 'Portuguese' }, + ], + showName: true, + }, + }, +}; diff --git a/packages/webapp/.storybook/state.js b/packages/webapp/.storybook/state.js index 32c03cac10..05c7a0b35f 100755 --- a/packages/webapp/.storybook/state.js +++ b/packages/webapp/.storybook/state.js @@ -1635,8 +1635,8 @@ export default { entities: { 1: { certification_id: 1, - certification_translation_key: 'ORGANIC', - certification_type: 'Organic', + certification_translation_key: 'THIRD_PARTY_ORGANIC', + certification_type: 'Third-party Organic', }, 2: { certification_id: 2, diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 24b1d4b0cf..a074d3fc7a 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -1,6 +1,6 @@ { "name": "litefarm-webapp", - "version": "3.6.4.1", + "version": "3.6.5", "description": "LiteFarm Web application", "type": "module", "scripts": { @@ -54,6 +54,7 @@ "react": "^18.2.0", "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.12", "react-google-login": "^5.2.2", "react-hook-form": "^7.40.0", "react-i18next": "^11.18.6", diff --git a/packages/webapp/pnpm-lock.yaml b/packages/webapp/pnpm-lock.yaml index 12bd03d1ee..b60223af70 100644 --- a/packages/webapp/pnpm-lock.yaml +++ b/packages/webapp/pnpm-lock.yaml @@ -121,6 +121,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.12 + version: 4.0.12(react@18.2.0) react-google-login: specifier: ^5.2.2 version: 5.2.2(react-dom@18.2.0)(react@18.2.0) @@ -7446,11 +7449,11 @@ packages: resolution: {integrity: sha512-YppvPa1qMyC+oCQJ3tf7Quzpf2NnBlvIRLPJiGAMssUwX5qE0iKe9lTtkNwMaNxEvzz6rDxewSlz+f/MWr4gPw==} dev: true - /@storybook/channels@8.0.0-alpha.0: - resolution: {integrity: sha512-QMDocSVZwyG8EnN4j6N8atejFPbfHTqge+fNDVWUVN1UpNOxAIMOrwrNWcieWB6IpM70k2+HYjcn1cGoAbWT2g==} + /@storybook/channels@8.0.0-alpha.17: + resolution: {integrity: sha512-TZKHO8K6d+Y7UDMQr1P2lqOeZ6TtkxDrcbDHauk47Bh/b4BtIJ78PBBZVDt198zw0kL3CAQ1CVNvdTaSIDOBXw==} dependencies: - '@storybook/client-logger': 8.0.0-alpha.0 - '@storybook/core-events': 8.0.0-alpha.0 + '@storybook/client-logger': 8.0.0-alpha.17 + '@storybook/core-events': 8.0.0-alpha.17 '@storybook/global': 5.0.0 qs: 6.11.2 telejson: 7.2.0 @@ -7519,8 +7522,8 @@ packages: '@storybook/global': 5.0.0 dev: true - /@storybook/client-logger@8.0.0-alpha.0: - resolution: {integrity: sha512-ppQal8eH1YVOiEf9Wg8hKksAf2pF++uSOJcRygkX3KZNCtW6YsSQOZbYsHtNmgzq0wi0ugAg9K8XC0WZUfz2vA==} + /@storybook/client-logger@8.0.0-alpha.17: + resolution: {integrity: sha512-qsMTZD9HA34Jv6HezF6MhO8McYnQUOiEfVoUquVJVeuVcnnuQ5Fi8XXdxhcEMAAFqpWPL24twGFuDY2zkXyCvQ==} dependencies: '@storybook/global': 5.0.0 dev: true @@ -7680,8 +7683,8 @@ packages: resolution: {integrity: sha512-sNnqgO5i5DUIqeQfNbr987KWvAciMN9FmMBuYdKjVFMqWFyr44HTgnhfKwZZKl+VMDYkHA9Do7UGSYZIKy0P4g==} dev: true - /@storybook/core-events@8.0.0-alpha.0: - resolution: {integrity: sha512-9LEyuEL9Bufni7T5FGSlz1tVJu+zyJZmnAF8YCD8QdG61F+HEPiWHypzOUVvLC1+rJRZ5nfe7GVkRr9/FS+TSQ==} + /@storybook/core-events@8.0.0-alpha.17: + resolution: {integrity: sha512-yG8fzR8y8+3ZPBMGWgiyOM8z0Yjp0VDgr42xKe+6lg+ssFZRIrWKanrsb/IUkkqbiwEitfod43BiZiqqNkIMlA==} dependencies: ts-dedent: 2.2.0 dev: true @@ -7851,14 +7854,14 @@ packages: '@storybook/preview-api': 7.0.27 dev: true - /@storybook/instrumenter@8.0.0-alpha.0: - resolution: {integrity: sha512-3tUFmjtR9eZxCm1k1QhNr4bAv1OhGA4FXA+H07doGbLgvITOkzi31Mq1C6jMln/4F7fFEGHNnhWkeojxP57TkQ==} + /@storybook/instrumenter@8.0.0-alpha.17: + resolution: {integrity: sha512-PXbi59y0QjAOgitp0vyhOXm6InG7iEV+thkLisKnWPkmV6VSyw9gRehAbAW0LnfEcj8JWaXRvJEHKxHB5+C2HQ==} dependencies: - '@storybook/channels': 8.0.0-alpha.0 - '@storybook/client-logger': 8.0.0-alpha.0 - '@storybook/core-events': 8.0.0-alpha.0 + '@storybook/channels': 8.0.0-alpha.17 + '@storybook/client-logger': 8.0.0-alpha.17 + '@storybook/core-events': 8.0.0-alpha.17 '@storybook/global': 5.0.0 - '@storybook/preview-api': 8.0.0-alpha.0 + '@storybook/preview-api': 8.0.0-alpha.17 '@vitest/utils': 0.34.6 util: 0.12.5 dev: true @@ -7971,21 +7974,21 @@ packages: util-deprecate: 1.0.2 dev: true - /@storybook/preview-api@8.0.0-alpha.0: - resolution: {integrity: sha512-4Yjh7Eu/5NhBN50ysEEIHuyR/FrQY8g1xFejLMlKdhoVGFRUtuI+qi1KxgxtXfiSxY58xPM8vdKq6EtyKamPrA==} + /@storybook/preview-api@8.0.0-alpha.17: + resolution: {integrity: sha512-F7xFSJr2K8sXLdFE9HJzS4T9YyPXFxCk3NbTu8EljXZvZfNpphCQRz/uEPdZzOp3Cuqn/0+vh6j9hvQ/m/OB3A==} dependencies: - '@storybook/channels': 8.0.0-alpha.0 - '@storybook/client-logger': 8.0.0-alpha.0 - '@storybook/core-events': 8.0.0-alpha.0 + '@storybook/channels': 8.0.0-alpha.17 + '@storybook/client-logger': 8.0.0-alpha.17 + '@storybook/core-events': 8.0.0-alpha.17 '@storybook/csf': 0.1.2 '@storybook/global': 5.0.0 - '@storybook/types': 8.0.0-alpha.0 + '@storybook/types': 8.0.0-alpha.17 '@types/qs': 6.9.7 dequal: 2.0.3 lodash: 4.17.21 memoizerific: 1.11.3 qs: 6.11.2 - synchronous-promise: 2.0.17 + tiny-invariant: 1.3.1 ts-dedent: 2.2.0 util-deprecate: 1.0.2 dev: true @@ -8204,8 +8207,8 @@ packages: /@storybook/testing-library@0.0.14-next.1: resolution: {integrity: sha512-1CAl40IKIhcPaCC4pYCG0b9IiYNymktfV/jTrX7ctquRY3akaN7f4A1SippVHosksft0M+rQTFE0ccfWW581fw==} dependencies: - '@storybook/client-logger': 8.0.0-alpha.0 - '@storybook/instrumenter': 8.0.0-alpha.0 + '@storybook/client-logger': 8.0.0-alpha.17 + '@storybook/instrumenter': 8.0.0-alpha.17 '@testing-library/dom': 8.20.1 '@testing-library/user-event': 13.5.0(@testing-library/dom@8.20.1) ts-dedent: 2.2.0 @@ -8248,11 +8251,10 @@ packages: file-system-cache: 2.3.0 dev: true - /@storybook/types@8.0.0-alpha.0: - resolution: {integrity: sha512-BEowwnvOINs27DRorIoKzKzXMqcgG1m0O6/v5XL/pHT8F9rB+GkD2jYEPlPg5+AdtVZT6aDbOzBlRpAkq/bu9Q==} + /@storybook/types@8.0.0-alpha.17: + resolution: {integrity: sha512-aKldT3ZJ2a1rJVML8s6WHS4xXjvH1krxn3vwMUaigzAI3B5V+BRrZyXAaqgTKPqsKLprliCI1M04gL6H90MJ+g==} dependencies: - '@storybook/channels': 8.0.0-alpha.0 - '@types/babel__core': 7.20.3 + '@storybook/channels': 8.0.0-alpha.17 '@types/express': 4.17.17 file-system-cache: 2.3.0 dev: true @@ -8403,7 +8405,7 @@ packages: engines: {node: '>=12'} dependencies: '@babel/code-frame': 7.22.5 - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 '@types/aria-query': 5.0.1 aria-query: 5.1.3 chalk: 4.1.2 @@ -8417,7 +8419,7 @@ packages: engines: {node: '>=14'} dependencies: '@babel/code-frame': 7.22.5 - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 '@types/aria-query': 5.0.1 aria-query: 5.1.3 chalk: 4.1.2 @@ -8445,7 +8447,7 @@ packages: optional: true dependencies: '@adobe/css-tools': 4.3.2 - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 '@types/jest': 28.1.3 aria-query: 5.3.0 chalk: 3.0.0 @@ -8463,7 +8465,7 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 '@testing-library/dom': 8.20.1 dev: true @@ -10116,7 +10118,7 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 cosmiconfig: 7.1.0 resolve: 1.22.2 @@ -17902,7 +17904,7 @@ packages: resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==} engines: {node: '>=10'} dependencies: - '@babel/runtime': 7.22.5 + '@babel/runtime': 7.23.5 dev: true /popmotion@11.0.3: @@ -18320,6 +18322,15 @@ packages: react-is: 18.1.0 dev: true + /react-error-boundary@4.0.12(react@18.2.0): + resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.23.5 + react: 18.2.0 + dev: false + /react-google-login@5.2.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-JUngfvaSMcOuV0lFff7+SzJ2qviuNMQdqlsDJkUM145xkGPVIfqWXq9Ui+2Dr6jdJWH5KYdynz9+4CzKjI5u6g==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. diff --git a/packages/webapp/public/locales/en/certifications.json b/packages/webapp/public/locales/en/certifications.json index d58c6db329..fbcff2109d 100644 --- a/packages/webapp/public/locales/en/certifications.json +++ b/packages/webapp/public/locales/en/certifications.json @@ -1,4 +1,4 @@ { - "ORGANIC": "Organic", + "THIRD_PARTY_ORGANIC": "Third-party organic", "PGS": "Participatory guarantee system" } diff --git a/packages/webapp/public/locales/en/common.json b/packages/webapp/public/locales/en/common.json index 70b87244e8..9640a1a5de 100644 --- a/packages/webapp/public/locales/en/common.json +++ b/packages/webapp/public/locales/en/common.json @@ -20,6 +20,8 @@ "DO_NOT_SHOW": "Don’t show this message again.", "EDIT": "Edit", "EDIT_DATE": "Edit date", + "EDITING": "Editing...", + "ENTER_VALUE": "Enter value", "EXPORT": "Export", "FINISH": "Finish", "FROM": "from", @@ -41,6 +43,7 @@ "NOTES": "Notes", "OK": "OK", "OPTIONAL": "(optional)", + "OR": "or", "OTHER": "Other", "PAST": "Past", "PLANNED": "Planned", diff --git a/packages/webapp/public/locales/en/message.json b/packages/webapp/public/locales/en/message.json index 383034f5b0..0b24c8fb33 100644 --- a/packages/webapp/public/locales/en/message.json +++ b/packages/webapp/public/locales/en/message.json @@ -121,6 +121,16 @@ "EDIT": "crop plan successfully updated" } }, + "PRODUCT": { + "ERROR": { + "CREATE": "Failed to create product", + "UPDATE": "Failed to update product" + }, + "SUCCESS": { + "CREATE": "Successfully created product", + "UPDATE": "Successfully updated product" + } + }, "REPEAT_PLAN": { "ERROR": { "POST": "Failed to repeat {{planName}}" diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json index 6a2be0e542..783de4075c 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -14,9 +14,34 @@ "TELL_US_ABOUT_YOUR_FARM": "Tell us about your farm" }, "ADD_PRODUCT": { + "ADD_ANOTHER_PRODUCT": "Add another product", + "ADDITIONAL_NUTRIENTS": "Additional nutrients", + "AMMONIUM": "Ammonium (NH₄)", + "BORON": "Boron (B)", + "BUTTON_WARNING": "Any changes will affect all tasks involving this product", + "CALCIUM": "Calcium (Ca)", + "COMPOSITION": "Composition", + "COMPOSITION_ERROR": "Error: The total percentage of N, P, K, and additional nutrients must not exceed 100%. Please adjust your values.", + "COPPER": "Copper (Cu)", + "DRY_FERTILISER": "Dry", + "DRY_MATTER_CONTENT": "Dry matter content", + "EDIT_PRODUCT_DETAILS": "Edit product details", + "FERTILISER_TYPE": "Is this product dry or liquid?", + "LIQUID_FERTILISER": "Liquid", + "MAGNESIUM": "Magnesium (Mg)", + "MANGANESE": "Manganese (Mn)", + "MOISTURE_CONTENT": "Moisture content", + "NITRATE": "Nitrate (NO₃)", + "NITROGEN": "Nitrogen (N)", + "PHOSPHOROUS": "Phosphorous (P₂O₅)", + "POTASSIUM": "Potassium (K₂O)", "PRESS_ENTER": "Type and press enter to add...", + "PRODUCT_DETAILS": "Product details", "PRODUCT_LABEL": "Product", - "SUPPLIER_LABEL": "Supplier" + "SAVE_PRODUCT": "Save product", + "SULFUR": "Sulfur (S)", + "SUPPLIER_LABEL": "Supplier", + "WHAT_YOU_WILL_BE_APPLYING": "What will you be applying?" }, "ADD_TASK": { "ADD_A_CUSTOM_TASK": "Add a custom task", @@ -42,6 +67,7 @@ "CUSTOM_TASK_TYPE": "Custom Task Type", "DO_YOU_NEED_TO_OVERRIDE": "Do you need to override the assignees wage for this task?", "DO_YOU_WANT_TO_ASSIGN": "Do you want to assign the task now?", + "DUPLICATE_NAME": "A product with this name already exists. Please choose another.", "EDIT_CUSTOM_TASK": "Edit custom task", "FIELD_WORK_VIEW": { "OTHER_TYPE_OF_FIELD_WORK": "Describe the type of field work", @@ -138,14 +164,34 @@ "SELECT_ALL_PLANS": "Select all plans", "SELECT_TASK_TYPE": "Select task type", "SOIL_AMENDMENT_VIEW": { + "ADVANCED": "Advanced", + "APPLICATION_METHOD": "Application method", + "APPLICATION_RATE": "Application rate", + "APPLIED_TO": "Applied to <1>{{percentOfArea}}% of your <4>{{locationArea}} {{locationAreaUnit}} {{locationType}}", + "APPLIED_TO_MULTIPLE": "Applied to <1>{{ percentOfArea }}% of your {{locationCount}} locations with total area <5>{{ locationArea }} {{ locationAreaUnit }}", + "BANDED": "Banded", + "BROADCAST": "Broadcast", + "FERTIGATION": "Fertigation", + "FOLIAR": "Foliar", + "FURROW_HOLE": "Furrow / hole", + "FURROW_HOLE_DEPTH": "Furrow / Hole depth", + "FURROW_HOLE_DEPTH_PLACEHOLDER": "At what depth was the amendment applied?", "IS_PERMITTED": "Is the soil amendment in the permitted substances list?", "MOISTURE_RETENTION": "Moisture retention", "NUTRIENT_AVAILABILITY": "Nutrient availability", "OTHER": "Other", + "OTHER_METHOD": "Tell us more about the application method", + "OTHER_METHOD_PLACEHOLDER": "Describe your soil amendment method...", "OTHER_PURPOSE": "Describe the purpose", + "PERECENT_TO_AMEND": "% of location area to amend", "PH": "pH", "PURPOSE": "Purpose", - "STRUCTURE": "Structure" + "QUANTITY": "Quantity to apply", + "SIDE_DRESS": "Sidedress (surface)", + "STRUCTURE": "Structure", + "TOTAL_AREA": "Total application area", + "VOLUME": "Volume", + "WEIGHT": "Weight" }, "TASK": "task", "TASK_NOTES_CHAR_LIMIT": "Notes must be less than 10,000 characters", @@ -365,7 +411,6 @@ "MANAGEMENT_PLANS": "Crop Plans", "MANAGEMENT_TAB": "Management", "ORGANIC": "Is the seed or crop certified organic?", - "ORGANIC_COMPLIANCE": "Organic Compliance", "PERENNIAL": "Perennial", "TREATED": "Have the seeds for this crop been treated?" }, @@ -490,6 +535,13 @@ "LOCATION": "Location", "TASK": "Task" }, + "ERROR_FALLBACK": { + "CONTACT": "Still stuck in the mud? No worries, we're here to pull you out: <1>{{supportEmail}}", + "MAIN": "Sometimes LiteFarm gets lost and just needs a bit of help. One of these usually resolves the problem:", + "RELOAD": "Reload the page", + "SUBTITLE": "Don't worry, it's not you, it's us.", + "TITLE": "Oops! Seems like this page has wandered off!" + }, "EXPENSE": { "ADD_EXPENSE": { "ADD_CUSTOM_EXPENSE": "Add custom expense", @@ -685,8 +737,11 @@ "ADD_TITLE": "Add to your map", "AREAS": "Areas", "BARN": "Barn", + "BUFFER_ZONE": "Buffer zone", "BZ": "Buffer zone", "CA": "Ceremonial area", + "CEREMONIAL_AREA": "Ceremonial area", + "FARM_SITE_BOUNDARY": "Farm site boundary", "FENCE": "Fence", "FIELD": "Field", "FSB": "Farm site boundary", @@ -697,6 +752,7 @@ "LABEL": "Labels", "LINES": "Lines", "NA": "Natural area", + "NATURAL_AREA": "Natural area", "POINTS": "Points", "RESIDENCE": "Residence", "SATELLITE": "Satellite background", @@ -704,6 +760,7 @@ "SHOW_ALL": "Show all", "SURFACE_WATER": "Surface water", "TITLE": "Filter your map", + "WATER_VALVE": "Water valve", "WATERCOURSE": "Watercourse", "WV": "Water valve" }, @@ -836,9 +893,9 @@ "REVENUE_TYPE": "Revenue types", "TITLE": "Filter transactions" }, - "MANAGE_CUSTOM_TYPE": "Manage custom type", "REPORT": { "DATES": "Dates", + "FILE_TITLE": "Financial Report", "SETTINGS": "Export Settings", "TRANSACTION": "Transaction", "TRANSACTIONS": "Transactions" @@ -1803,6 +1860,7 @@ "SELECT_DATE": "Select the task date", "SELECT_TASK_LOCATIONS": "Select the task location(s)", "SELECT_WILD_CROP": "This task targets a wild crop", + "SOIL_AMENDMENT_LOCATION": "Select the soil amendment location(s)", "STATUS": { "ABANDONED": "Abandoned", "COMPLETED": "Completed", diff --git a/packages/webapp/public/locales/es/certifications.json b/packages/webapp/public/locales/es/certifications.json index 6ee9fa9064..1bd8e68949 100644 --- a/packages/webapp/public/locales/es/certifications.json +++ b/packages/webapp/public/locales/es/certifications.json @@ -1,4 +1,4 @@ { - "ORGANIC": "Orgánica", + "THIRD_PARTY_ORGANIC": "Orgánica por auditoría", "PGS": "Sistema participativo de garantía" } diff --git a/packages/webapp/public/locales/es/common.json b/packages/webapp/public/locales/es/common.json index 576d73ecdf..080ce04cd7 100644 --- a/packages/webapp/public/locales/es/common.json +++ b/packages/webapp/public/locales/es/common.json @@ -2,7 +2,7 @@ "ABANDON": "Abandonar", "ACTIVE": "Activo", "ADD": "Agregar", - "ADD_ANOTHER_ITEM": "Agregar otro item", + "ADD_ANOTHER_ITEM": "Agregar otro ítem", "ADD_ITEM": "Agregar {{itemName}}", "ALL": "Todo", "AMOUNT": "Monto", @@ -20,6 +20,8 @@ "DO_NOT_SHOW": "No vuelva a mostrar este mensaje.", "EDIT": "Editar", "EDIT_DATE": "Editar fecha", + "EDITING": "Editando...", + "ENTER_VALUE": "Ingresar valor", "EXPORT": "Exportar", "FINISH": "Terminar", "FROM": "de", @@ -41,12 +43,13 @@ "NOTES": "Notas", "OK": "Está bien", "OPTIONAL": "(opcional)", + "OR": "o", "OTHER": "Otro", "PAST": "Pasado", "PLANNED": "Planificado", "PROCEED": "Proceder", "QUANTITY": "Cantidad", - "REMOVE_ITEM": "Eliminar item", + "REMOVE_ITEM": "Eliminar ítem", "REQUIRED": "Requerido", "RETIRE": "Retirar", "SAVE": "Guardar", diff --git a/packages/webapp/public/locales/es/crop.json b/packages/webapp/public/locales/es/crop.json index 01d7ce0977..51ec810d20 100644 --- a/packages/webapp/public/locales/es/crop.json +++ b/packages/webapp/public/locales/es/crop.json @@ -1,5 +1,5 @@ { - "ABACA_MANILA_HEMP": "Abaca (Manila hemp)", + "ABACA_MANILA_HEMP": "Abacá (cáñamo de Manila)", "ALFALFA_FOR_FODDER": "Alfalfa para forraje", "ALFALFA_FOR_SEED": "Alfalfa para semillas", "ALMOND": "Almendra", @@ -10,7 +10,7 @@ "ARRACHA": "Zanahoria (blanca)", "ARROWROOT": "Arrurruz", "ARTICHOKE": "Alcachofas", - "ASPARAGUS": "Esparrago", + "ASPARAGUS": "Espárragos", "AVOCADO": "Aguacate", "BAJRA_PEARL_MILLET": "Bajra (Mijo perla)", "BAMBARA_GROUNDNUT": "Vigna subterranea", @@ -32,7 +32,7 @@ "BLACK_WATTLE": "Acacia negra", "BLUEBERRY": "Arándano", "BRAZIL_NUT": "Nuez de Brasil", - "BREADFRUIT": "Arbol de pan", + "BREADFRUIT": "Árbol del pan", "BROAD_BEAN": "Haba", "BROAD_BEAN_DRY": "Haba, seca", "BROAD_BEAN_HARVESTED_GREEN": "Haba, cosechada verde", @@ -46,7 +46,7 @@ "CABBAGE_FOR_FODDER": "Repollo, para forraje", "CABBAGE_RED_WHITE_SAVOY": "Repollo (rojo, blanco, savoy)", "CACAO_COCOA": "Cacao", - "CANTALOUPE": "Cantalupo", + "CANTALOUPE": "Melón cantalupo", "CARAWAY_SEEDS": "Semillas de alcaravea", "CARDAMOM": "Cardamomo", "CARDOON": "Cardo", @@ -207,6 +207,7 @@ "MULBERRY_FOR_SILKWORMS": "Mora para gusanos de seda", "MUSHROOMS": "Champiñones", "MUSTARD": "Mostaza", + "MUSTARD_FOR_SEED": "Mostaza, por semilla", "NECTARINE": "Durazno nectarina", "NIGER_SEED": "Semillas de niger", "NUTMEG": "Nuez moscada", @@ -339,7 +340,6 @@ "WHEAT": "Trigo", "YAM": "Camote", "YERBA_MATE": "Yerba mate", - "MUSTARD_FOR_SEED": "Mostaza, por semilla", "ABIU": "Caimito", "ACAI_PALM": "Palma de acaí", "ACHACHA": "Achacha", diff --git a/packages/webapp/public/locales/es/crop_nutrients.json b/packages/webapp/public/locales/es/crop_nutrients.json index e32716a9c3..e88d0aa3ca 100644 --- a/packages/webapp/public/locales/es/crop_nutrients.json +++ b/packages/webapp/public/locales/es/crop_nutrients.json @@ -1,6 +1,6 @@ { "INIT_KC": "Kc Inicial", - "MID_KC":"Kc Medio", + "MID_KC": "Kc Medio", "END_KC": "Kc Final", "MAX_HEIGHT": "Altura maxima", "PROTEIN": "Proteina", @@ -8,21 +8,21 @@ "ENERGY": "Energia", "CALCIUM": "Calcio", "IRON": "Hierro", - "MAGNESIUM":"Magnesio", + "MAGNESIUM": "Magnesio", "PH": "PH", - "K":"K", + "K": "K", "NA": "NA", - "ZINC":"Zinc", + "ZINC": "Zinc", "COPPER": "Cobre", "MANGANESE": "Manganeso", "VITA_RAE": "Vitamina A", "VITAMIN_C": "Vitamina C", "THIAMIN": "Thiamina", - "RIBOFLAVIN":"Riboflavina", + "RIBOFLAVIN": "Riboflavina", "NIACIN": "Niacina", - "VITAMIN_B6":"Vitamina B6", + "VITAMIN_B6": "Vitamina B6", "FOLATE": "Folato", - "VITAMIN_B12":"Vitamina B12", + "VITAMIN_B12": "Vitamina B12", "MAX_ROOTING": "Enraizamiento máximo", "NUTRIENT_CREDITS": "Creditos nutricionales" } \ No newline at end of file diff --git a/packages/webapp/public/locales/es/disease.json b/packages/webapp/public/locales/es/disease.json index 8535f54f10..4259e3457f 100644 --- a/packages/webapp/public/locales/es/disease.json +++ b/packages/webapp/public/locales/es/disease.json @@ -191,7 +191,7 @@ "FUSARIUM_HEAD_BLIGHT": "Tizón de la cabeza por Fusariosis", "ALTERNARIA_BLACK_SPOT_AND_FRUIT_ROT": "Mancha negra por Alternaria y pudrición de la fruta", "CERCOSPORA_FRUIT_AND_LEAF_SPOT": "Mancha del fruto y la hoja por Cercospora", - "ANTHRACNOSE_OF_CURRANT _ & _ GOOSEBERRY": "Antracnosis de grosellas y pasas de Corinto", + "ANTHRACNOSE_OF_CURRANT_&_GOOSEBERRY": "Antracnosis de grosellas y pasas de Corinto", "ERGOT_OF_SORGHUM": "Cornezuelo de sorgo", "BAKANAE_AND_FOOT_ROT": "Bakanae y podredumbre de la raíz", "SEPTORIA_SPOT": "Mancha de Septoria", @@ -228,7 +228,7 @@ "CHILLI_CERCOSPORA_LEAF_SPOT": "Mancha foliar del ají por Cercospora ", "POWDERY_MILDEW_OF_CEREALS": "Moho polvoriento de los cereales", "SEPTORIA_LEAF_SPOT": "Mancha foliar por Septoria", - "MELANOSA": "Melanosa", + "MELANOSE": "Melanosa", "BROWN_RUST_OF_RYE": "Pudrición marrón del centeno", "DAMPING-OFF": "Mal de los semilleros o almácigos", "VASCULAR_WILT_OF_BANANA": "Marchitamiento vascular del plátano o banana", @@ -337,7 +337,7 @@ "WESTERN_FLOWER_THRIPS": "Trips florales occidentales", "WESTERN_PLANT_BUG": "Insecto de la planta occidental", "BLACK_PARLATORIA_SCALE": "Escala de parlatoria negra", - "STINK_BUGS_ON_CORN, _MILLET_AND_SORGHUM": "Chinches apestosas en maíz, mijo y sorgo", + "STINK_BUGS_ON_CORN,_MILLET_AND_SORGHUM": "Chinches apestosas en maíz, mijo y sorgo", "WHORL_MAGGOT": "Gusano verticilo", "GREENHORNED_CATERPILLARS": "Orugas de cuernos verdes", "ORIENTAL_FRUIT_MOTH": "Polilla oriental de la fruta", @@ -449,7 +449,7 @@ "SLUG": "Babosa", "PHYSIOLOGICAL_LEAF_SPOT": "Mancha fisiológica de la hoja", "GIANT_ARROWHEAD": "Punta de flecha gigante", - "ALCALINIDAD": "Alcalinidad", + "ALKALINITY": "Alcalinidad", "EAR_COCKLE_EELWORM": "Gusano anguila (earcockle)", "VOLE": "Campañol", "CITRUS_NEMATODE": "Nemátodo de los cítricos", diff --git a/packages/webapp/public/locales/es/filter.json b/packages/webapp/public/locales/es/filter.json index 469f001be7..6ef500230b 100644 --- a/packages/webapp/public/locales/es/filter.json +++ b/packages/webapp/public/locales/es/filter.json @@ -24,14 +24,14 @@ }, "TASKS": { "LOCATION": "Ubicación", + "STATUS": "Estatus", + "SUPPLIERS": "Proveedores", + "ACTIVE": "Activo", "ABANDONED": "Abandonada", "COMPLETED": "Completada", - "FOR_REVIEW": "Para la revisión", "LATE": "Tarde", "PLANNED": "Planificada", - "STATUS": "Estatus", - "SUPPLIERS": "Proveedores", - "ACTIVE": "Activo" + "FOR_REVIEW": "Para la revisión" }, "FILTER": { "VALID_ON": "Válido en", diff --git a/packages/webapp/public/locales/es/message.json b/packages/webapp/public/locales/es/message.json index e132131d9d..ddbec2c198 100644 --- a/packages/webapp/public/locales/es/message.json +++ b/packages/webapp/public/locales/es/message.json @@ -121,6 +121,16 @@ "EDIT": "Plan de cultivo actualizado con éxito" } }, + "PRODUCT": { + "ERROR": { + "CREATE": "Error al crear el producto", + "UPDATE": "Error al actualizar el producto" + }, + "SUCCESS": { + "CREATE": "Producto creado con éxito", + "UPDATE": "Producto actualizado con éxito" + } + }, "REPEAT_PLAN": { "ERROR": { "POST": "Fallo al repetir {{planName}}" diff --git a/packages/webapp/public/locales/es/translation.json b/packages/webapp/public/locales/es/translation.json index a5bbeffb58..ef2c617edb 100644 --- a/packages/webapp/public/locales/es/translation.json +++ b/packages/webapp/public/locales/es/translation.json @@ -1,6 +1,6 @@ { "ADD_FARM": { - "ADDRESS_IS_REQUIRED": "Dirección es obligatoria", + "ADDRESS_IS_REQUIRED": "La dirección es obligatoria", "DISABLE_GEO_LOCATION": "Los servicios de ubicación deben estar habilitados para encontrar su ubicación actual.", "ENTER_A_VALID_ADDRESS": "Por favor ingrese dirección válida o coordine", "ENTER_LOCATION_PLACEHOLDER": "Ingresa una ubicación", @@ -14,9 +14,34 @@ "TELL_US_ABOUT_YOUR_FARM": "Cuéntenos sobre su finca" }, "ADD_PRODUCT": { + "ADD_ANOTHER_PRODUCT": "Añadir otro producto", + "ADDITIONAL_NUTRIENTS": "Nutrientes adicionales", + "AMMONIUM": "Amonio (NH₄)", + "BORON": "Boro (B)", + "BUTTON_WARNING": "Cualquier cambio afectará a todas las tareas relacionadas con este producto", + "CALCIUM": "Calcio (Ca)", + "COMPOSITION": "Composición", + "COMPOSITION_ERROR": "Error: El porcentaje total de N, P, K y nutrientes adicionales no debe superar el 100%. Por favor, ajuste sus valores.", + "COPPER": "Cobre (Cu)", + "DRY_FERTILISER": "Sólido", + "DRY_MATTER_CONTENT": "Contenido de materia seca", + "EDIT_PRODUCT_DETAILS": "Editar detalles del producto", + "FERTILISER_TYPE": "¿Este producto es sólido o líquido?", + "LIQUID_FERTILISER": "Líquido", + "MAGNESIUM": "Magnesio (Mg)", + "MANGANESE": "Manganeso (Mn)", + "MOISTURE_CONTENT": "Contenido de humedad", + "NITRATE": "Nitrato (NO₃)", + "NITROGEN": "Nitrógeno (N)", + "PHOSPHOROUS": "Fósforo (P₂O₅)", + "POTASSIUM": "Potasio (K₂O)", "PRESS_ENTER": "Escriba y presione enter para agregar...", + "PRODUCT_DETAILS": "Detalles del producto", "PRODUCT_LABEL": "Producto", - "SUPPLIER_LABEL": "Proveedor/a" + "SAVE_PRODUCT": "Guardar producto", + "SULFUR": "Azufre (S)", + "SUPPLIER_LABEL": "Proveedor/a", + "WHAT_YOU_WILL_BE_APPLYING": "¿Qué aplicará?" }, "ADD_TASK": { "ADD_A_CUSTOM_TASK": "Agregar una tarea personalizada", @@ -42,6 +67,7 @@ "CUSTOM_TASK_TYPE": "Tipo de tarea personalizada", "DO_YOU_NEED_TO_OVERRIDE": "¿Necesita alterar el salario de los cesionarios para esta tarea?", "DO_YOU_WANT_TO_ASSIGN": "¿Quiere asignar la tarea ahora?", + "DUPLICATE_NAME": "Ya existe un producto con este nombre. Por favor, elija otro.", "EDIT_CUSTOM_TASK": "Editar la tarea personalizada", "FIELD_WORK_VIEW": { "OTHER_TYPE_OF_FIELD_WORK": "Describe el tipo de trabajo de campo", @@ -138,14 +164,34 @@ "SELECT_ALL_PLANS": "Seleccionar todos los planes", "SELECT_TASK_TYPE": "Seleccionar tipo de tarea", "SOIL_AMENDMENT_VIEW": { + "ADVANCED": "Avanzado", + "APPLICATION_METHOD": "Método de aplicación", + "APPLICATION_RATE": "Tasa de aplicación", + "APPLIED_TO": "Aplicado a <1>{{percentOfArea}}% de su <4>{{locationArea}} {{locationAreaUnit}} {{locationType}}", + "APPLIED_TO_MULTIPLE": "Se aplica a <1>{{ percentOfArea }}% de sus {{locationCount}} ubicaciones con área total <5>{{ locationArea }} {{ locationAreaUnit }}", + "BANDED": "En bandas", + "BROADCAST": "Transmisión", + "FERTIGATION": "Fertirrigación", + "FOLIAR": "Foliar", + "FURROW_HOLE": "Surco / agujero", + "FURROW_HOLE_DEPTH": "Profundidad de surco / agujero", + "FURROW_HOLE_DEPTH_PLACEHOLDER": "¿A qué profundidad se aplicó el aditivo?", "IS_PERMITTED": "¿Está el fetilizante del suelo en la lista de sustancias permitidas?", "MOISTURE_RETENTION": "Retención de humedad", "NUTRIENT_AVAILABILITY": "Disponibilidad de nutrientes", "OTHER": "Otro", + "OTHER_METHOD": "Más información sobre el método de aplicación", + "OTHER_METHOD_PLACEHOLDER": "Describa su método de aditivo del suelo...", "OTHER_PURPOSE": "Describe el propósito", + "PERECENT_TO_AMEND": "% del área a realizar aditivos", "PH": "pH", "PURPOSE": "Propósito", - "STRUCTURE": "Estructura" + "QUANTITY": "Cantidad a aplicar", + "SIDE_DRESS": "Lateral (superficie)", + "STRUCTURE": "Estructura", + "TOTAL_AREA": "Superficie total de aplicación", + "VOLUME": "Volumen", + "WEIGHT": "Peso" }, "TASK": "tarea", "TASK_NOTES_CHAR_LIMIT": "Las notas deben tener menos de 10,000 caracteres", @@ -195,7 +241,7 @@ "SUBTITLE_ONE": "Esta es una lista de certificadores de", "SUBTITLE_TWO": "con los que trabajamos en su país", "TITLE": "Que tipo de certificación?", - "TOOLTIP": "No ve su certificación? Litefarm esta dedicado en apoyar la agricultura sostenible y las certificaciones son una gran parte de esto. Solicita otra certificacion aqui y haremos nuestro mejor esfuerzo en incorporarla en la aplicacion." + "TOOLTIP": "¿No ve su certificación? LiteFarm se dedica a apoyar la agricultura sostenible y las certificaciones son una parte importante de ello. Solicite otra certificación aquí y haremos todo lo posible para incorporarla a la aplicación." }, "CERTIFIER_SELECTION": { "INFO": "Esto probablemente significa que Litefarm no trabaja actualmente con su certificador - Lo sentimos! Litefarm si produce formularios genericos que se pueden ayudar en la mayoria de los casos.", @@ -343,7 +389,7 @@ }, "FILTER_TITLE": "Filtro de catálogo de cultivos ", "GENUS": "Género", - "HERE_YOU_CAN": "Acá puede", + "HERE_YOU_CAN": "Aquí puede:", "LETS_BEGIN": "Vamos a comenzar", "NEW_CROP_NAME": "Nombre de nuevo cultivo", "NO_RESULTS_FOUND": "No se han encontrado resultados. Por favor, cambie sus filtros.", @@ -454,7 +500,7 @@ "NOTES_CHAR_LIMIT": "Las notas deben tener menos de 10,000 caracteres", "SPOTLIGHT": { "CDC": "Cuando llegue el momento de generar su exportación de certificación, LiteFarm exportará automáticamente todos los documentos de cumplimiento que sean válidos en la fecha de exportación que especifique. Los documentos de cumplimiento se archivarán automáticamente cuando expiran.", - "HERE_YOU_CAN": "Aqui uno puede:", + "HERE_YOU_CAN": "Aquí puede:", "YOU_CAN_ONE": "Cargar los documentos que desee incluir en la exportación de su certificación.", "YOU_CAN_THREE": "Archivar documentos innecesarios", "YOU_CAN_TWO": "Clasificar y controlar las fechas de vencimiento de su documento" @@ -489,6 +535,13 @@ "LOCATION": "ubicación", "TASK": "tarea" }, + "ERROR_FALLBACK": { + "CONTACT": "¿Aún atascado en el barro? No te preocupes, estamos aquí para sacarte: <1>{{supportEmail}}", + "MAIN": "A veces, LiteFarm se pierde y sólo necesita un poco de ayuda. Uno de estos suele resolver el problema:", + "RELOAD": "Volver a cargar la página", + "SUBTITLE": "No se preocupe, no es usted, somos nosotros/as.", + "TITLE": "¡Uy! ¡Parece que esta página se ha desviado!" + }, "EXPENSE": { "ADD_EXPENSE": { "ADD_CUSTOM_EXPENSE": "Agregar gasto personalizado", @@ -579,7 +632,7 @@ }, "TITLE": "Agregar sensores al mapa", "UPLOAD_ERROR_LINK": "Haga clic aquí para verlos", - "UPLOAD_ERROR_MESSAGE": "Hubo algunos problemas con tu subida.", + "UPLOAD_ERROR_MESSAGE": "Hubo algunos problemas con su carga. ", "UPLOAD_INSTRUCTION_MESSAGE": "para el formato correcto.", "UPLOAD_LINK_MESSAGE": "Descargar plantilla", "UPLOAD_PLACEHOLDER": "Subir archivo CSV", @@ -620,7 +673,7 @@ "DOWNLOAD": "Descargar", "EMAIL_TO_ME": "Enviar a mi correo", "EMAILING": "Enviando", - "TITLE": "Exporta su mapa" + "TITLE": "Exporte su mapa" }, "FARM_SITE_BOUNDARY": { "EDIT_TITLE": "Editar limites de la granja", @@ -668,7 +721,7 @@ "NAME": "Nombre de invernadero", "NON_ORGANIC": "No orgánico", "ORGANIC": "Orgánico", - "SUPPLEMENTAL_LIGHTING": "Tiene luz suplementaria?", + "SUPPLEMENTAL_LIGHTING": "¿Tiene luz suplementaria?", "TITLE": "Agregar invernadero", "TRANSITIONING": "En transicion" }, @@ -684,8 +737,11 @@ "ADD_TITLE": "Agregar a su mapa", "AREAS": "Áreas", "BARN": "Granero", + "BUFFER_ZONE": "Zona delimitada", "BZ": "Zona delimitada", "CA": "Área ceremonial", + "CEREMONIAL_AREA": "Área ceremonial", + "FARM_SITE_BOUNDARY": "Limites de granja", "FENCE": "Cerca", "FIELD": "Campo", "FSB": "Limites de granja", @@ -696,6 +752,7 @@ "LABEL": "Etiqueta", "LINES": "Lineas", "NA": "Área natural", + "NATURAL_AREA": "Área natural", "POINTS": "Puntos", "RESIDENCE": "Residencia", "SATELLITE": "Imagen de Satelite", @@ -703,6 +760,7 @@ "SHOW_ALL": "Muestra todo", "SURFACE_WATER": "Agua de superficie", "TITLE": "Filtra su mapa", + "WATER_VALVE": "Valvula de agua", "WATERCOURSE": "Arroyo", "WV": "Valvula de agua" }, @@ -721,10 +779,10 @@ "ADD": "Añadir ubicaciones a su mapa", "ADD_TITLE": "Añadir a su mapa", "EXPORT": "Descargar o compartir su mapa", - "EXPORT_TITLE": "Exporta su mapa", + "EXPORT_TITLE": "Exporte su mapa", "FILTER": "Cambiar que se ve en el mapa", "FILTER_TITLE": "Filtra su mapa", - "HERE_YOU_CAN": "Aqui puede: " + "HERE_YOU_CAN": "Aquí puede:" }, "SURFACE_WATER": { "EDIT_TITLE": "Editar agua de superficie", @@ -835,9 +893,9 @@ "REVENUE_TYPE": "Tipos de ingresos", "TITLE": "Filtrar transacciones" }, - "MANAGE_CUSTOM_TYPE": "Administrar tipos personalizados", "REPORT": { "DATES": "Fechas", + "FILE_TITLE": "Informe económico", "SETTINGS": "Configuración del reporte", "TRANSACTION": "Transacción", "TRANSACTIONS": "Transacciones" @@ -899,7 +957,6 @@ "MAMMALS": "Mamíferos", "PLANTS": "Plantas", "SPECIES_COUNT_one": "{{count}} especies", - "SPECIES_COUNT_many": "{{count}} especies", "SPECIES_COUNT_other": "{{count}} especies", "TITLE": "Biodiversidad" }, @@ -914,7 +971,6 @@ "PRICES": { "INFO": "Le mostramos la trayectoria de sus precios de venta en comparacion a los precios de venta de los mismos bienes cerca de su granja. Esta información es recolectada a través de la red de LiteFarm.", "NEARBY_FARMS_one": "El precio del mercado se basa en {{count}} granja en su área local", - "NEARBY_FARMS_many": "El precio del mercado se basa en {{count}} granjas en su área local", "NEARBY_FARMS_other": "El precio del mercado se basa en {{count}} granjas en su área local", "NETWORK_PRICE": "Precio de red", "NO_ADDRESS": "Actualmente no tienes una direccion en Litefarm. Por favor actualizala en su Perfil para obtener data de ventas cercanas.", @@ -1147,7 +1203,7 @@ "RATE_THIS_MANAGEMENT_PLAN": "Valora este plan de administración", "REMOVE_PIN": "Quitar el pin", "REPEATED_MP_SPOTLIGHT": { - "BODY": "Ud. puede hacer clic para ver todos los planes de cultivo en éste grupo. Si modifica un plan de cultivo individual no cambiará nada en los otros planes del mismo grupo.", + "BODY": "Ud. puede hacer click para ver todos los planes de cultivo en este grupo. Si modifica un plan de cultivo individual, no cambiará nada en los otros planes del mismo grupo.", "TITLE": "!Felicidades! Ud. ha creado su primer plan de cultivo repetido" }, "ROW_METHOD": { @@ -1168,7 +1224,7 @@ "SELECT_A_SEEDING_LOCATION": "Seleccione una ubicación de siembra", "SELECT_CURRENT_LOCATION": "Seleccione la ubicación actual del cultivo", "SELECTED_STARTING_LOCATION": "Seleccione siempre esta como la ubicación de inicio para los cultivos que se trasplantarán", - "SPOTLIGHT_HERE_YOU_CAN": "Aqui une puede:", + "SPOTLIGHT_HERE_YOU_CAN": "Aquí puede:", "STARTED": "Empecemos", "STATUS": { "ABANDONED": "Abandonado", @@ -1252,7 +1308,7 @@ "NONE_TO_DISPLAY": "No hay notificaciones para mostrar.", "PAGE_TITLE": "Notificaciones", "SENSOR_BULK_UPLOAD_FAIL": { - "BODY": "La carga del tuvo errores. Haga clic en “Llevame allí” para ver los detalles.", + "BODY": "La carga del sensor tuvo errores. Haga clic en “Llevame allí” para ver los detalles.", "TITLE": "Errores al cargar sensores" }, "SENSOR_BULK_UPLOAD_SUCCESS": { @@ -1402,9 +1458,9 @@ "CLEAR_ALL": "Borrar todo" }, "RELEASE": { - "BETTER": "MISSING", + "BETTER": "¡LiteFarm ha mejorado!", "LITEFARM_UPDATED": "LiteFarm v{{version}} ya está disponible!", - "NOTES": "MISSING" + "NOTES": "Notas de publicación" }, "REPEAT_PLAN": { "AFTER": "Después", @@ -1804,6 +1860,7 @@ "SELECT_DATE": "Seleccione la fecha de la tarea", "SELECT_TASK_LOCATIONS": "Seleccione la(s) ubicación(es) de la tarea", "SELECT_WILD_CROP": "Esta tarea se dirige a un cultivo silvestre", + "SOIL_AMENDMENT_LOCATION": "Seleccione la(s) ubicación(es) de aditivo del suelo", "STATUS": { "ABANDONED": "Abandonada", "COMPLETED": "Completada", @@ -1813,7 +1870,6 @@ }, "TASK": "tarea", "TASKS_COUNT_one": "{{count}} tarea", - "TASKS_COUNT_many": "{{count}} tareas", "TASKS_COUNT_other": "{{count}} tareas", "TRANSPLANT": "Trasplantar", "TRANSPLANT_LOCATIONS": "Seleccione una ubicación para el trasplante", diff --git a/packages/webapp/public/locales/fr/certifications.json b/packages/webapp/public/locales/fr/certifications.json index f915d9797b..c16b801939 100644 --- a/packages/webapp/public/locales/fr/certifications.json +++ b/packages/webapp/public/locales/fr/certifications.json @@ -1,4 +1,4 @@ { - "ORGANIC": "Biologique", + "THIRD_PARTY_ORGANIC": "Biologique par audit", "PGS": "Système de participation garantie" } diff --git a/packages/webapp/public/locales/fr/common.json b/packages/webapp/public/locales/fr/common.json index 3282a7b372..8a9248ea52 100644 --- a/packages/webapp/public/locales/fr/common.json +++ b/packages/webapp/public/locales/fr/common.json @@ -20,6 +20,8 @@ "DO_NOT_SHOW": "Ne plus afficher ce message", "EDIT": "Modifier", "EDIT_DATE": "Modifier la Date", + "EDITING": "Édition...", + "ENTER_VALUE": "Saisir une valeur", "EXPORT": "Exporter", "FINISH": "Terminer", "FROM": "De", @@ -41,6 +43,7 @@ "NOTES": "Notes", "OK": "OK", "OPTIONAL": "(optionnel)", + "OR": "ou", "OTHER": "Autre", "PAST": "Passé", "PLANNED": "Planifié", diff --git a/packages/webapp/public/locales/fr/crop_nutrients.json b/packages/webapp/public/locales/fr/crop_nutrients.json index d35d185cb9..c237ac48e5 100644 --- a/packages/webapp/public/locales/fr/crop_nutrients.json +++ b/packages/webapp/public/locales/fr/crop_nutrients.json @@ -1,5 +1,5 @@ { - "INIT_KC": "Kc Initial", + "INIT_KC": "Kc initial", "MID_KC": "Kc de Moyen Terme", "END_KC": "Kc Terminal", "MAX_HEIGHT": "Taille Maximale", @@ -25,4 +25,4 @@ "VITAMIN_B12": "Vitamine B12", "MAX_ROOTING": "Enracinement Maximal", "NUTRIENT_CREDITS": "Nutriments supplémentaires" -} +} \ No newline at end of file diff --git a/packages/webapp/public/locales/fr/disease.json b/packages/webapp/public/locales/fr/disease.json index 4b72ff3d18..29369bda24 100644 --- a/packages/webapp/public/locales/fr/disease.json +++ b/packages/webapp/public/locales/fr/disease.json @@ -68,7 +68,7 @@ "BLACK_SPOT_DISEASE_OF_PAPAYA": "Maladie des points noirs de la papaye", "ANTHRACNOSE_OF_BANANA": "Anthracnose de la banane", "PHAEOSPHAERIA_LEAF_SPOT": "Tache des feuilles de Phaeosphaeria", - "ALTERNaria_BROWN_SPOT": "Tache brune Alternaria", + "ALTERNARIA_BROWN_SPOT": "Tache brune d'Alternaria", "EARLY_BLIGHT_OF_TOMATO": "Le mildiou de la tomate", "POWDERY_MILDEW": "Oïdium", "PEACH_SCAB": "Telle du pêcher", @@ -96,7 +96,7 @@ "LEAF_AND_GLUME_BLOTCH_OF_WHEAT": "Tache de feuille et de glume de blé", "LEAF_MOLD_OF_TOMATO": "Moule en feuille de tomate", "FUSARIUM_WILT": "Flétrissure fusarienne", - "ROUGH_LEAF_SPOT_OF_SORGHU": "Tache rugueuse des feuilles de sorgho", + "ROUGH_LEAF_SPOT_OF_SORGHUM": "Tache rugueuse des feuilles du sorgho", "ROOT_ROT_OF_COTTON": "Pourriture des racines du coton", "HEAD_SMUT": "Head Smut", "OLIVE_LEAF_SPOT": "Tache de la feuille d'olivier", @@ -128,7 +128,7 @@ "CERCOSPORA_LEAF_SPOT_OF_EGGPLANT": "Tache Cercosporique de l'Aubergine", "PEA_RUST": "Rouille de pois", "BOTTOM_ROT": "Pourriture du bas", - "BUD_NECROSE": "Nécrose des bourgeons", + "BUD_NECROSIS": "Nécrose des bourgeons", "ESCA": "Esca", "JACKET_ROT": "Veste pourriture", "ARMILLARIA_ROOT_ROT": "Pourriture des racines d'Armillaria", @@ -143,7 +143,7 @@ "WHITE_ROT": "Pourriture blanche", "PLUM_RUST": "Rouille prune", "KARNAL_BUNT_OF_WHEAT": "Karnal carie de blé", - " EYESPOT ": " Eyespot ", + "EYESPOT": "Tache oculaire", "DOWNY_MILDEW_ON_GRAPE": "Mildiou sur raisin", "CLUBROOT_OF_CANOLA": "Clubroot de Canola", "COMMON_LEAF_SPOT": "Tache commune des feuilles", @@ -179,7 +179,7 @@ "NET_BLOTCH": "Net Blotch", "BLACK_LEG_OF_RAPESEED": " Cuisse noire de colza ", "POTATO_LATE_BLIGHT": "Le mildiou de la pomme de terre", - "BLACK_SHNK": "Queue noire", + "BLACK_SHANK": "Colletotrichum nicotianae", "BROWN_SPOT_OF_SOYBEAN": "Tache brune du soja", "ROOT_ROT_OF_LENTIL": "Pourriture des racines de lentille", "PYRICULARIA_LEAF_SPOT": "Tache des feuilles de Pyricularia", @@ -189,13 +189,13 @@ "SHOTHOLE_DISEASE": "Maladie des trous", "ANTHRACNOSE_OF_POMEGRANATE": "Anthracnose de Grenade", "FUSARIUM_HEAD_BLIGHT": "Fusarium de la tête", - "ALTERNaria_BLACK_SPOT_AND_FRUIT_ROT": "Tache noire alternarienne et pourriture des fruits", + "ALTERNARIA_BLACK_SPOT_AND_FRUIT_ROT": "Tache noire d'Alternaria et pourriture des fruits", "CERCOSPORA_FRUIT_AND_LEAF_SPOT": "Tache des fruits et des feuilles Cercospora", "ANTHRACNOSE_OF_CURRANT_&_GOOSEBERRY": "Anthracnose de groseille et de groseille", - "ERGOT_OF_SORGHU": "Ergot de Sorgho", + "ERGOT_OF_SORGHUM": "Ergot du sorgho", "BAKANAE_AND_FOOT_ROT": "Bakanae et pourriture des pieds", "SEPTORIA_SPOT": "Septoria Spot", - "ALTERNaria_LEAF_SPOT_OF_PEANUT": "Tache alternarienne de l'arachide", + "ALTERNARIA_LEAF_SPOT_OF_PEANUT": "Tache foliaire d'Alternaria de l'arachide", "ANTHRACNOSE_OF_APPLE": "Anthracnose de Pomme", "ASHY_STEM_BLIGHT_OF_BEAN": "Pourriture charbonneuse sèche", "SILVER_LEAF": "Feuille d'argent", @@ -210,7 +210,7 @@ "GREASY_SPOT_OF_CITRUS": "Tache graisseuse d'agrumes", "CHARCOAL_STALK_ROT": "Pourriture des tiges de charbon", "LOOSE_SMUT": "Charbon Nu", - "ALTERNaria_LEAF_SPOT_OF_COTTON": "Tache des feuilles d'Alternaria du coton", + "ALTERNARIA_LEAF_SPOT_OF_COTTON": "Tache foliaire d'Alternaria du coton", "BROWN_LEAF_SPOT": "Tache brune des feuilles", "PHOMA_SORGHINA_IN_RICE": "Phoma Sorghina au riz", "LEAF_STRIPE_OF_BARLEY": "Feuille rayée d'orge", @@ -223,12 +223,12 @@ "SUGARCANE_PINEAPPLE_DISEASE": "Maladie de l'ananas de la canne à sucre", "PURPLE_SEED_STAIN_OF_SOYBEAN": "Tache violette de graines de soja", "ANTHRACNOSE_OF_BLACKGRAM": "Anthracnose de Blackgram", - "GUMMOSE": "Gumosis", + "GUMMOSIS": "Gommose", "MILLET_RUST": "Millet Rouille", "CHILLI_CERCOSPORA_LEAF_SPOT": "Chilli Cercospora Leaf Spot", "POWDERY_MILDEW_OF_CEREALS": "L'oïdium des céréales", "SEPTORIA_LEAF_SPOT": "Tache septorienne", - "Mélanose": "Mélanose", + "MELANOSE": "Mélanose", "BROWN_RUST_OF_RYE": "Rouille brune du seigle", "DAMPING-OFF": "Damping-Off", "VASCULAR_WILT_OF_BANANA": "Flétrissement vasculaire du bananier", @@ -266,7 +266,7 @@ "CLUBROOT": "Clubroot", "UDBATTA_DISEASE_OF_RICE": "Maladie d'Udbatta du riz", "MEALYBUG_ON_PAPAYA": "Cochenille sur papaye", - "puceron": "puceron", + "APHID": "Puceron", "RICE_LEAFROLLER": "Enrouleuse de riz", "GREEN_PEACH_APHID": " Puceron vert du pêcher ", "POTATO_BEETLE": "Scarabée de la pomme de terre", @@ -296,10 +296,10 @@ "VARIEGATED_GRASSHOPPER": "Sauterelle panachée", "BLACK_CUTWORM": "Ver-gris noir", "FALL_ARMYWORM": "Légionnaire d'automne", - "Mealybug": "Cochenille", + "MEALYBUG": "Cochenille", "REDCURRANT_BLISTER_APHID": " Puceron blister de la groseille", "COTTON_LEAF_HOPPER": "Coton Leaf Hopper", - "SORGHU_MIDGE": "Cécidomyie du sorgho", + "SORGHUM_MIDGE": "Mouche du sorgho", "HELICOVERPA_ON_SOYBEAN": "Helicoverpa sur Soja", "SEMILOOPER": "Semilooper", "BANANA_SCALE_INSECT": "Cochenille du bananier", @@ -337,7 +337,7 @@ "WESTERN_FLOWER_THRIPS": "Thrips des fleurs de l'Ouest", "WESTERN_PLANT_BUG": "Punaise occidentale", "BLACK_PARLATORIA_SCALE": "Échelle de Parlatoria noire", - "STINK_BUGS_ON_CORN,_MILLET_AND_SORGHU": "Punaises puantes sur le maïs, le millet et le sorgho", + "STINK_BUGS_ON_CORN,_MILLET_AND_SORGHUM": "Punaises sur le maïs, le millet et le sorgho", "WHORL_MAGGOT": "Mouche de la mouche", "GREENHORNED_CATERPILLARS": "Les chenilles à cornes vertes", "ORIENTAL_FRUIT_MOTH": "Tite orientale des fruits", @@ -449,20 +449,20 @@ "SLUG": "Limace", "PHYSIOLOGICAL_LEAF_SPOT": "Tache physiologique des feuilles", "GIANT_ARROWHEAD": "Pointe de flèche géante", - "ALCALINITÉ": "Alcalinité", + "ALKALINITY": "Alcalinité", "EAR_COCKLE_EELWORM": "Ver de l'anguille de la coque de l'oreille", - "campagnol": "campagnol", + "VOLE": "Campagnol", "CITRUS_NEMATODE": "Nématode des agrumes", "CYST_NEMATODE": "Nématode à kyste", "BIOMPHALARIA_SNAILS": "Escargots Biomphalaria", "LESION_NEMATODE": " Lésion Nématode ", "ABIOTIC_SUNBURN": "Coup de soleil abiotique", - "ABIOTIC_SUNBURN_IN_PISTACHE": "Coup de soleil abiotique à la pistache", - "NÉMATODE": "Nématode", + "ABIOTIC_SUNBURN_IN_PISTACHIO": "Coup de soleil abiotique sur pistache", + "NEMATODE": "Nématode", "FERTILIZER_OR_PESTICIDE_BURN": "Frais d'engrais ou de pesticides", "ALGAL_LEAF_SPOT": "Tache des feuilles d'algues", "GOLDEN_APPLE_SNAIL": "Escargot pomme d'or", - "SAIN": "Sain", + "HEALTHY": "Sain", "PESTICIDE_BURN": "Brûlure de pesticides", "MUNGBEAN_YELLOW_MOSAIC_VIRUS": "Virus de la mosaïque jaune du haricot mungo", "URD_BEAN_LEAF_CRINKLE_VIRUS": "Virus Urd Bean Leaf Crinkle", @@ -492,7 +492,7 @@ "STERILITY_MOSAIC": "Mosaïque de stérilité", "SOYBEAN_MOSAIC_VIRUS": "Virus de la mosaïque du soja", "WHEAT_DWARF_VIRUS": "Virus nain du blé", - "MAIZE_LETHAL_NECROSE_DISEASE": "Maladie de nécrose létale du maïs", + "MAIZE_LETHAL_NECROSIS_DISEASE": "Maladie nécrotique létale du maïs", "TOMATO_YELLOW_LEAF_CURL_VIRUS": "Virus de l'enroulement des feuilles jaunes de la tomate", "CUCUMBER_MOSAIC_VIRUS_ON_BANANA": "Virus de la mosaïque du concombre sur banane", "BANANA_BRACT_MOSAIC_VIRUS": "Virus de la mosaïque de la bractée de la banane", @@ -518,11 +518,11 @@ "VIRUS": "Virus", "WEED": "Adventice" }, - "PESTICIDES": { + "PESTICIDE": { "ROUNDUP": "Roundup", "DIPEL": "Dipel 2X DF", "NEEM_OIL": "Huile de neem", - "SULFOCALCIUM_SYRUP": "Sirop de sulfocalcium", + "SULFOCALCIUM_SYRUP": "Sirop sulfocalcique", "BORDEAUX_MIXTURE": "Bouillie bordelaise" } } diff --git a/packages/webapp/public/locales/fr/filter.json b/packages/webapp/public/locales/fr/filter.json index 2c439d1aeb..11833db22e 100644 --- a/packages/webapp/public/locales/fr/filter.json +++ b/packages/webapp/public/locales/fr/filter.json @@ -13,25 +13,25 @@ "CLEANING_PRODUCT": "Produit Nettoyant", "CROP_COMPLIANCE": "Conformité de Culture", "FERTILIZING_PRODUCT": "Produit Fertilisant", - "INVOICES": "Facture", "OTHER": "Autre", "PEST_CONTROL_PRODUCT": "Produit Phytosanitaire", - "RECEIPTS": "Reçu", "SOIL_AMENDMENT": "Amendement", "SOIL_SAMPLE_RESULTS": "Analyse de sol", "WATER_SAMPLE_RESULTS": "Analyse d'eau", - "UNCATEGORIZED": "Non Classé" + "UNCATEGORIZED": "Non Classé", + "RECEIPTS": "Reçu", + "INVOICES": "Facture" }, "TASKS": { "LOCATION": "Lieu", + "STATUS": "Statut", + "SUPPLIERS": "Fournisseurs", + "ACTIVE": "Active", "ABANDONED": "Abandonnée", "COMPLETED": "Terminée", - "FOR_REVIEW": "Pour évaluer", "LATE": "En retard", "PLANNED": "Planifiée", - "STATUS": "Statut", - "SUPPLIERS": "Fournisseurs", - "ACTIVE": "Active" + "FOR_REVIEW": "Pour évaluer" }, "FILTER": { "VALID_ON": "Valable le", diff --git a/packages/webapp/public/locales/fr/message.json b/packages/webapp/public/locales/fr/message.json index df0fe7c679..f1dd9829c6 100644 --- a/packages/webapp/public/locales/fr/message.json +++ b/packages/webapp/public/locales/fr/message.json @@ -121,6 +121,16 @@ "EDIT": "Plan de culture mis à jour" } }, + "PRODUCT": { + "ERROR": { + "CREATE": "Erreur à la création du produit", + "UPDATE": "Erreur à la mise à jour produit" + }, + "SUCCESS": { + "CREATE": "Produit créé avec succès", + "UPDATE": "Produit mis à jour avec succès" + } + }, "REPEAT_PLAN": { "ERROR": { "POST": "Impossible de répéter {{planName}}" diff --git a/packages/webapp/public/locales/fr/translation.json b/packages/webapp/public/locales/fr/translation.json index bdb6f0ed65..58d058e80a 100644 --- a/packages/webapp/public/locales/fr/translation.json +++ b/packages/webapp/public/locales/fr/translation.json @@ -14,15 +14,40 @@ "TELL_US_ABOUT_YOUR_FARM": "Parlez-nous de votre exploitation" }, "ADD_PRODUCT": { + "ADD_ANOTHER_PRODUCT": "Ajouter un autre produit", + "ADDITIONAL_NUTRIENTS": "Nutriments supplémentaires", + "AMMONIUM": "Ammonium (NH₄)", + "BORON": "Bore (B)", + "BUTTON_WARNING": "Toute modification affectera toutes les tâches impliquant ce produit", + "CALCIUM": "Calcium (Ca)", + "COMPOSITION": "Composition", + "COMPOSITION_ERROR": "Erreur : Le pourcentage total de N, P, K et des nutriments supplémentaires ne doit pas dépasser 100 %. Veuillez ajuster vos valeurs.", + "COPPER": "Cuivre (Cu)", + "DRY_FERTILISER": "Sec", + "DRY_MATTER_CONTENT": "Teneur en matière sèche", + "EDIT_PRODUCT_DETAILS": "Modifier les détails du produit", + "FERTILISER_TYPE": "Ce produit est-il sec ou liquide ?", + "LIQUID_FERTILISER": "Liquide", + "MAGNESIUM": "Magnésium (Mg)", + "MANGANESE": "Manganèse (Mn)", + "MOISTURE_CONTENT": "Teneur en humidité", + "NITRATE": "Nitrate (NO₃)", + "NITROGEN": "Azote (N)", + "PHOSPHOROUS": "Phosphore (P₂O₅)", + "POTASSIUM": "Potassium (K₂O)", "PRESS_ENTER": "Tapez et appuyez sur Entrée pour ajouter...", + "PRODUCT_DETAILS": "Détails du produit", "PRODUCT_LABEL": "Produit", - "SUPPLIER_LABEL": "Fournisseur" + "SAVE_PRODUCT": "Enregistrer le produit", + "SULFUR": "Soufre (S)", + "SUPPLIER_LABEL": "Fournisseur", + "WHAT_YOU_WILL_BE_APPLYING": "Qu'allez-vous appliquer ?" }, "ADD_TASK": { "ADD_A_CUSTOM_TASK": "Ajouter une tâche personnalisée", "ADD_A_TASK": "Ajouter une tâche", "ADD_CUSTOM_TASK": "Ajouter une tâche personnalisée", - "AFFECT_PLANS": "Cette tâche affectera-t-elle les plans\u00a0?", + "AFFECT_PLANS": "Cette tâche affectera-t-elle les plans ?", "ASSIGN_ALL_TO_PERSON": "Assigner toutes les tâches à cette date à {{name}}", "ASSIGN_DATE": "Attribuer une date d'échéance", "ASSIGN_TASK": "Attribuer une tâche", @@ -30,9 +55,9 @@ "CANCEL": "création de tâches", "CLEANING_VIEW": { "ESTIMATED_WATER": "Consommation d'eau estimée", - "IS_PERMITTED": "Le produit de nettoyage est-il dans la liste des substances autorisées\u00a0?", - "WHAT_NEEDS_TO_BE": "Que faut-il nettoyer\u00a0?", - "WILL_CLEANER_BE_USED": "Un agent nettoyant ou désinfectant sera-t-il utilisé\u00a0?" + "IS_PERMITTED": "Le produit de nettoyage est-il dans la liste des substances autorisées ?", + "WHAT_NEEDS_TO_BE": "Que faut-il nettoyer ?", + "WILL_CLEANER_BE_USED": "Un agent nettoyant ou désinfectant sera-t-il utilisé ?" }, "CLEAR_ALL": "Tout effacer", "CLEAR_ALL_PLANS": "Effacer tous les plans", @@ -40,8 +65,9 @@ "CUSTOM_TASK_CHAR_LIMIT": "Le nom du type de tâche personnalisée ne peut pas dépasser 25 caractères", "CUSTOM_TASK_NAME": "Nom de la tâche personnalisée", "CUSTOM_TASK_TYPE": "Type de tâche personnalisée", - "DO_YOU_NEED_TO_OVERRIDE": "Avez-vous besoin de changer le salaire des employés pour cette tâche\u00a0?", - "DO_YOU_WANT_TO_ASSIGN": "Souhaitez-vous affecter la tâche maintenant\u00a0?", + "DO_YOU_NEED_TO_OVERRIDE": "Avez-vous besoin de changer le salaire des employés pour cette tâche ?", + "DO_YOU_WANT_TO_ASSIGN": "Souhaitez-vous affecter la tâche maintenant ?", + "DUPLICATE_NAME": "Un produit portant ce nom existe déjà. Veuillez en choisir un autre.", "EDIT_CUSTOM_TASK": "Modifier la tâche personnalisée", "FIELD_WORK_VIEW": { "OTHER_TYPE_OF_FIELD_WORK": "Décrivez le type de travail de terrain", @@ -66,9 +92,9 @@ "DONT_ASK": "Non, ne demandez plus pour cet employé", "FOR_THIS_TASK": "Oui, juste pour cette tâche", "SET_HOURLY_WAGE": "Oui, fixer un salaire horaire", - "WANT_TO_SET_HOURLY_WAGE": "Voulez-vous fixer un salaire horaire\u00a0?" + "WANT_TO_SET_HOURLY_WAGE": "Voulez-vous fixer un salaire horaire ?" }, - "HOW_MUCH_IS_HARVESTED": "Quelle quantité est récoltée\u00a0?", + "HOW_MUCH_IS_HARVESTED": "Quelle quantité est récoltée ?", "HR": "/h", "IRRIGATION_VIEW": { "BRAND_TOOLTIP": "Cette tâche est d'arroser votre emplacement. Pour configuer l'irrigation, créez plutôt une tâche de terrain.", @@ -80,13 +106,13 @@ "ESTIMATED_DURATION": "Durée estimée", "ESTIMATED_FLOW_RATE": "Débit estimé", "ESTIMATED_WATER_USAGE": "Utilisation estimée de l’eau", - "HOW_DO_YOU_MEASURE_WATER_USE_FOR_THIS_IRRIGATION_TYPE": "Comment mesurez-vous l'utilisation de l'eau pour ce type d'irrigation\u00a0?", + "HOW_DO_YOU_MEASURE_WATER_USE_FOR_THIS_IRRIGATION_TYPE": "Comment mesurez-vous l'utilisation de l'eau pour ce type d'irrigation ?", "IRRIGATED_AREA": "Superficie irriguée", "IRRIGATION_TYPE_CHAR_LIMIT": "Le type d'irrigation doit comporter moins de 100 caractères", "LOCATION_SIZE": "Taille de l'emplacement", - "NOT_SURE": "Pas sûr\u00a0?", + "NOT_SURE": "Pas sûr ?", "PERCENTAGE_LOCATION_TO_BE_IRRIGATED": "% de l’emplacement à irriguer", - "SET_AS_DEFAULT_MEASUREMENT_FOR_THIS_IRRIGATION_TYPE": "Sélectionner ce type de mesure par défaut pour ce type d'irrigation\u00a0?", + "SET_AS_DEFAULT_MEASUREMENT_FOR_THIS_IRRIGATION_TYPE": "Sélectionner ce type de mesure par défaut pour ce type d'irrigation ?", "SET_AS_DEFAULT_TYPE_FOR_THIS_LOCATION": "Sélectionner ce type par défaut pour cet emplacement", "TOTAL_WATER_USAGE": "Utilisation totale de l’eau", "TYPE": { @@ -107,7 +133,7 @@ "PHRASE2": "Si vous préférez calculer en fonction du ", "PHRASE3": "veuillez revenir en arrière et sélectionner" }, - "WHAT_TYPE_OF_IRRIGATION": "Quel type d'irrigation\u00a0?" + "WHAT_TYPE_OF_IRRIGATION": "Quel type d'irrigation ?" }, "MANAGE_CUSTOM_TASKS": "Gérer les tâches personnalisées", "NEED_MANAGEMENT_PLAN": "Vous avez besoin d'un plan de culture actif ou planifié avant de pouvoir programmer une tâche de récolte ou une tâche de plantation. Accédez au catalogue de cultures pour créer un plan maintenant.", @@ -118,7 +144,7 @@ "FOLIAR_SPRAY": "Spray foliaire", "HAND_WEEDING": "Sarclage à la main", "HEAT_TREATMENT": "Taille", - "IS_PERMITTED": "L'agent nuisible est-il dans la liste des substances autorisées\u00a0?", + "IS_PERMITTED": "L'agent nuisible est-il dans la liste des substances autorisées ?", "OTHER": "Autre", "OTHER_PEST": "Autre méthode", "PEST_CONTROL_METHOD": "Méthode de lutte antiparasitaire", @@ -131,28 +157,48 @@ "PLANTING_STOCK": "Matériel de plantation", "PLANTING_TASK": "Tâche de plantation", "PLANTING_TASK_MODAL": "Démarrer une nouvelle tâche de plantation crée un nouveau plan de gestion. Accédez au catalogue des cultures pour sélectionner la culture que vous souhaitez planter.", - "RETIRE_CUSTOM_TASK": "Retirer la tâche personnalisée\u00a0?", - "RETIRE_CUSTOM_TASK_CONTENT": "Voulez-vous vraiment supprimer cette tâche personnalisée\u00a0?", + "RETIRE_CUSTOM_TASK": "Retirer la tâche personnalisée ?", + "RETIRE_CUSTOM_TASK_CONTENT": "Voulez-vous vraiment supprimer cette tâche personnalisée ?", "SEED": "Semer", "SELECT_ALL": "Tout sélectionner", "SELECT_ALL_PLANS": "Sélectionner tous les plans", "SELECT_TASK_TYPE": "Sélectionnez le type de tâche", "SOIL_AMENDMENT_VIEW": { - "IS_PERMITTED": "L'amendement du sol est-il dans la liste des substances autorisées\u00a0?", + "ADVANCED": "Avancé", + "APPLICATION_METHOD": "Taux d'application", + "APPLICATION_RATE": "Taux d'application", + "APPLIED_TO": "Appliqué à <1>{{percentOfArea}}% de votre <4>{{locationArea}} {{locationAreaUnit}} {{locationType}}", + "APPLIED_TO_MULTIPLE": "Appliqué à <1>{{ percentOfArea }}% de vos emplacements {{locationCount}} avec une superficie totale <5>{{ locationArea }} {{ locationAreaUnit }}", + "BANDED": "En bande", + "BROADCAST": "En diffusion", + "FERTIGATION": "Fertigation", + "FOLIAR": "Foliaire", + "FURROW_HOLE": "Sillon / trou", + "FURROW_HOLE_DEPTH": "Profondeur du sillon / trou", + "FURROW_HOLE_DEPTH_PLACEHOLDER": "À quelle profondeur l'amendement a-t-il été appliqué?", + "IS_PERMITTED": "L'amendement du sol est-il dans la liste des substances autorisées ?", "MOISTURE_RETENTION": "Rétention d'humidité", "NUTRIENT_AVAILABILITY": "Disponibilité des nutriments", "OTHER": "Autre", + "OTHER_METHOD": "Dites-nous en plus sur la méthode d'application", + "OTHER_METHOD_PLACEHOLDER": "Décrivez votre méthode d'amendement du sol...", "OTHER_PURPOSE": "Décrivez l'objectif'", + "PERECENT_TO_AMEND": "% de la zone à amender", "PH": "pH", "PURPOSE": "Objectif", - "STRUCTURE": "Structure" + "QUANTITY": "Quantité à appliquer", + "SIDE_DRESS": "Sidedress (en surface)", + "STRUCTURE": "Structure", + "TOTAL_AREA": "Superficie totale d'application", + "VOLUME": "Volume", + "WEIGHT": "Poids" }, "TASK": "tâche", "TASK_NOTES_CHAR_LIMIT": "Les notes doivent comporter moins de 10 000 caractères", "TELL_US_ABOUT_YOUR_TASK_TYPE_ONE": "Décrivez cette tache", "TRANSPLANT_METHOD": "Méthode de repiquage", "WAGE_OVERRIDE": "Dérogation de salaire", - "WHAT_PLANTING_METHOD": "Quelle est la méthode de repiquage\u00a0?", + "WHAT_PLANTING_METHOD": "Quelle est la méthode de repiquage ?", "WILD_CROP": "Cultures sauvages" }, "BED_PLAN": { @@ -164,16 +210,16 @@ }, "BROADCAST_PLAN": { "AREA_USED": "Zone utilisée", - "HISTORICAL_PERCENTAGE_LOCATION": "Quel pourcentage de l'emplacement a été planté\u00a0?", + "HISTORICAL_PERCENTAGE_LOCATION": "Quel pourcentage de l'emplacement a été planté ?", "LOCATION_SIZE": "Taille de l'emplacement", "PERCENTAGE_LABEL": "% de l'emplacement", - "PERCENTAGE_LOCATION": "Quel pourcentage de l'emplacement plantez-vous\u00a0?", + "PERCENTAGE_LOCATION": "Quel pourcentage de l'emplacement plantez-vous ?", "PLANTING_NOTES": "Notes de plantation", "SEEDING_RATE": "Taux de semis" }, "CANCEL_FLOW_MODAL": { - "BODY": "Toutes les informations que vous avez saisies seront supprimées. Voulez-vous continuer\u00a0?", - "TITLE": "Annuler votre {{flow}}\u00a0?" + "BODY": "Toutes les informations que vous avez saisies seront supprimées. Voulez-vous continuer ?", + "TITLE": "Annuler votre {{flow}} ?" }, "CERTIFICATION": { "CERTIFICATION_EXPORT": { @@ -181,9 +227,9 @@ "CHANGE_CERTIFICATION_PREFERENCE": "modifiez vos préférences de certification", "CHANGE_CERTIFICATION_PREFERENCE_CAPITAL": "Modifier vos préférences de certification", "NO_CERTIFICATIONS": "Vous ne poursuivez actuellement aucune certification.", - "NO_LONGER_WORKING": "Vous n'essayez plus d'obtenir cette certification, ou ne travaillez plus avec cette organization\u00a0? Pas de problème\u00a0!", + "NO_LONGER_WORKING": "Vous n'essayez plus d'obtenir cette certification, ou ne travaillez plus avec cette organization ? Pas de problème !", "SUPPORTED_CERTIFICATION_ONE": "Vous êtes en train d'obtenir votre certification", - "SUPPORTED_CERTIFICATION_TWO": "de\u00a0:", + "SUPPORTED_CERTIFICATION_TWO": "de :", "UNSUPPORTED_CERTIFICATION_MESSAGE_ONE": "LiteFarm ne génère actuellement pas de documents pour ce certificateur. Cependant, nous pouvons exporter des formulaires génériques utiles pour la plupart des certificateurs. Sélectionnez 'Exporter' pour créer ces formulaires ou", "UNSUPPORTED_CERTIFICATION_MESSAGE_TWO": "pour voir s'il y a de nouveaux certificateurs disponibles dans votre région.", "UNSUPPORTED_CERTIFICATION_REQUEST_ONE": "Vous avez demandé la certification", @@ -194,64 +240,64 @@ "REQUEST_CERTIFICATION": "Demander un autre type de certification", "SUBTITLE_ONE": "Voici une liste de certificateurs", "SUBTITLE_TWO": "les certificateurs avec lesquels nous travaillons dans votre pays.", - "TITLE": "Quel type de certification\u00a0?", - "TOOLTIP": "Vous ne voyez pas votre certification\u00a0? LiteFarm se consacre au soutien de l'agriculture durable et les certifications en sont une grande partie. Demandez une autre certification ici et nous ferons de notre mieux pour l'intégrer dans l'application." + "TITLE": "Quel type de certification ?", + "TOOLTIP": "Vous ne voyez pas votre certification ? LiteFarm se consacre au soutien de l'agriculture durable et les certifications en sont une grande partie. Demandez une autre certification ici et nous ferons de notre mieux pour l'intégrer dans l'application." }, "CERTIFIER_SELECTION": { "INFO": "Cela signifie probablement que LiteFarm ne fonctionne pas actuellement avec votre certificateur - désolé00a0 Cependant, LiteFarm peut générer un formulaire générique qui est utile pour la certification dans la plupart des cas.", - "NOT_FOUND": "Vous ne voyez pas votre certificateur\u00a0?", + "NOT_FOUND": "Vous ne voyez pas votre certificateur ?", "REQUEST_CERTIFIER": "Demander un certificateur", - "TITLE": "De quelle organisation obtenez-vous votre certification\u00a0?" + "TITLE": "De quelle organisation obtenez-vous votre certification ?" }, "INPUT_PLACEHOLDER": "Tapez pour rechercher", "INTERESTED_IN_CERTIFICATION": { - "PARAGRAPH": "Prévoyez-vous obtenir ou renouveler votre certification pour la saison\u00a0?", + "PARAGRAPH": "Prévoyez-vous obtenir ou renouveler votre certification pour la saison ?", "TITLE": "Préférences de certification", "WHY_ANSWER": "LiteFarm génère les documents requis pour la certification biologique. Certaines informations seront obligatoires." }, "REQUEST_CERTIFIER": { "LABEL": "Certificateur demandé", - "REQUEST": "Quel certificateur souhaitez-vous demander\u00a0?", + "REQUEST": "Quel certificateur souhaitez-vous demander ?", "SORRY_ONE": "Nous sommes désolés - nous ne travaillons actuellement avec aucun certificateur", - "SORRY_THREE": "certificateurs dans votre pays. Désirez-vous ajouter une certification\u00a0?", - "SORRY_TWO": "certificateurs. Désirez-vous ajouter une certification\u00a0?", + "SORRY_THREE": "certificateurs dans votre pays. Désirez-vous ajouter une certification ?", + "SORRY_TWO": "certificateurs. Désirez-vous ajouter une certification ?", "TITLE": "Demander l'addition d'un certificateur" }, "SUMMARY": { - "BAD_NEWS": "LiteFarm ne collecte actuellement pas les informations dont vous avez besoin pour générer vos documents de certification - désolé\u00a0!", + "BAD_NEWS": "LiteFarm ne collecte actuellement pas les informations dont vous avez besoin pour générer vos documents de certification - désolé !", "BAD_NEWS_INFO": "Cependant, nous pouvons créer des formulaires génériques qui sont utiles pour la plupart des certificateurs. Nous indiquerons ces informations à travers l'application avec une icône de feuille.", "CERTIFICATION": "certification", - "GOOD_NEWS": "Bonne nouvelle\u00a0! LiteFarm peut rassembler les informations dont vous avez besoin pour générer vos documents de certification\u00a0!", + "GOOD_NEWS": "Bonne nouvelle ! LiteFarm peut rassembler les informations dont vous avez besoin pour générer vos documents de certification !", "INFORMATION": "Nous indiquerons ces informations à travers l'application avec une icône de feuille.", - "TITLE": "Vous êtes intéressé à postuler pour\u00a0:", + "TITLE": "Vous êtes intéressé à postuler pour :", "YOUR_CERTIFICATION": "Votre certification" } }, "CERTIFICATIONS": { - "COULD_NOT_CONTACT_CERTIFIER": "LiteFarm ne travaille pas spécifiquement avec votre certificateur, il aura peut-être besoin d'informations complémentaires. Vos documents spécifiques seront envoyés à\u00a0:", + "COULD_NOT_CONTACT_CERTIFIER": "LiteFarm ne travaille pas spécifiquement avec votre certificateur, il aura peut-être besoin d'informations complémentaires. Vos documents spécifiques seront envoyés à :", "EXPORT": "Exporter", "EXPORT_DOCS": "Exporter les documents de certification", "EXPORT_DOWNLOADING_MESSAGE": "Téléchargement de vos documents de certification biologique ...", "EXPORT_FILE_TITLE": "Certification biologique", - "FILES_ARE_READY": "Vos fichiers de certification sont maintenant prêts à être exportés. Nous les enverrons à\u00a0:", + "FILES_ARE_READY": "Vos fichiers de certification sont maintenant prêts à être exportés. Nous les enverrons à :", "FLOW_TITLE": "export des documents de certification", - "GOOD_NEWS": "Bonne nouvelle\u00a0!", - "HAVE_ALL_INFO": "Il semblerait que LiteFarm dispose de toutes les informations dont nous avons besoin pour traiter vos documents de certification. Nous vous les enverrons à\u00a0:", + "GOOD_NEWS": "Bonne nouvelle !", + "HAVE_ALL_INFO": "Il semblerait que LiteFarm dispose de toutes les informations dont nous avons besoin pour traiter vos documents de certification. Nous vous les enverrons à :", "NEXT_WE_WILL_CHECK": "Nous vérifierons ensuite si votre certificateur a besoin d'informations supplémentaires pour traiter votre soumission de certification.", - "NOTE_CANNOT_RESUBMIT": "Remarque\u00a0: Une fois l'enquête envoyée, vous ne pourrez plus modifier vos réponses. Pour les modifier après l'envoi, commencez une nouvelle exportation.", + "NOTE_CANNOT_RESUBMIT": "Remarque : Une fois l'enquête envoyée, vous ne pourrez plus modifier vos réponses. Pour les modifier après l'envoi, commencez une nouvelle exportation.", "ORGANIC_CERTIFICATION_FROM": "Certification biologique de", "SELECT_REPORTING_PERIOD": "Sélectionnez votre période de rapport", - "UH_OH": "Oups\u00a0!", + "UH_OH": "Oups !", "WOULD_LIKE_ANSWERS": "Votre certificateur aimerait que vous répondiez à quelques questions supplémentaires avant que nous puissions exporter vos documents." }, "CERTIFICATIONS_MODAL": { "MAYBE_LATER": "Peut-être plus tard", "STEP_ONE": { - "DESCRIPTION": "Nous avons ajouté des outils d'assistance pour les certifications et les certificateurs\u00a0! Voulez-vouz voir ce qui est disponible dans votre région\u00a0?", - "TITLE": "Nouvelle fonctionnalité\u00a0!" + "DESCRIPTION": "Nous avons ajouté des outils d'assistance pour les certifications et les certificateurs ! Voulez-vouz voir ce qui est disponible dans votre région ?", + "TITLE": "Nouvelle fonctionnalité !" }, "STEP_TWO": { - "DESCRIPTION": "Pas de problème\u00a0! Vous pouvez ajouter les certifications et les certificateurs plus tard dans la section «\u00a0Mon exploitation\u00a0».", + "DESCRIPTION": "Pas de problème ! Vous pouvez ajouter les certifications et les certificateurs plus tard dans la section « Mon exploitation ».", "TITLE": "Affichage des certifications" } }, @@ -291,28 +337,28 @@ "ADD_CROP": "Ajouter une culture", "ADD_IMAGE": "Ajouter une image personnalisée", "ANNUAL": "Annuelle", - "ANNUAL_OR_PERENNIAL": "La culture est-elle annuelle ou vivace\u00a0?", + "ANNUAL_OR_PERENNIAL": "La culture est-elle annuelle ou vivace ?", "CULTIVAR_PLACEHOLDER": "P. ex. Rouge Délicieuse", "CULTIVAR_SUBTEXT": "En savoir plus sur les cultivars", "DUPLICATE_VARIETY": "Une variété de culture porte déjà ce nom dans votre exploitation", "EDIT_CROP": "Modifier la culture", "EDIT_MODAL": { - "BODY": "La modification de cette culture ne modifiera aucun plan de culture existant. Seuls les plans de culture créés après vos modifications seront impactés. Procéder à la modification\u00a0?", - "TITLE": "Modifier la culture\u00a0?" + "BODY": "La modification de cette culture ne modifiera aucun plan de culture existant. Seuls les plans de culture créés après vos modifications seront impactés. Procéder à la modification ?", + "TITLE": "Modifier la culture ?" }, - "IS_GENETICALLY_ENGINEERED": "La culture est-elle génétiquement modifiée\u00a0?", - "IS_ORGANIC": "La semence ou la culture est-elle certifiée biologique\u00a0?", + "IS_GENETICALLY_ENGINEERED": "La culture est-elle génétiquement modifiée ?", + "IS_ORGANIC": "La semence ou la culture est-elle certifiée biologique ?", "NEED_DOCUMENT_GENETICALLY_ENGINEERED": "Votre certificateur peut demander des documents à l'appui de votre affirmation que cette culture n'est pas génétiquement modifiée.", "NEED_DOCUMENT_PERFORM_SEARCH": "Votre certificateur peut demander des documents à l'appui de votre recherche.", "NEED_DOCUMENT_TREATED": "Votre certificateur peut demander des documents décrivant les traitements.", "NUTRIENTS_IN_EDIBLE_PORTION": "Nutriments dans la portion comestible (pour 100g)", "PERENNIAL": "Vivace", - "PERFORM_SEARCH": "Avez-vous effectué une recherche de disponibilité commerciale\u00a0?", + "PERFORM_SEARCH": "Avez-vous effectué une recherche de disponibilité commerciale ?", "PHYSIOLOGY_AND_ANATOMY": "Physiologie et anatomie", "REPEAT_PLAN_MODAL": { "DELETED_PLANS": "Les plans de culture supprimés qui faisaient partie de cette répétition ne seront pas affichés." }, - "TREATED": "Les semences de cette culture ont-elles été traitées\u00a0?", + "TREATED": "Les semences de cette culture ont-elles été traitées ?", "VARIETAL_IMAGE": "Personnaliser l’image pour la variété ou le cultivar", "VARIETAL_IMAGE_INFO": "Si vous souhaitez que cette variété ou ce cultivar ait une image différente de la culture par défaut, vous pouvez personnaliser l’image ici.", "VARIETAL_PLACEHOLDER": "P. ex. Cabernet sauvignon", @@ -326,9 +372,9 @@ "ADD_CROP": "Ajoutez une nouvelle culture", "ADD_CROPS_T0_YOUR_FARM": "Ajouter des cultures à votre exploitation", "ADD_TO_YOUR_FARM": "Ajouter à votre exploitation", - "CAN_NOT_FIND": "Vous ne trouvez pas ce que vous cherchez\u00a0?", + "CAN_NOT_FIND": "Vous ne trouvez pas ce que vous cherchez ?", "CANCEL": "création de culture", - "COVER_CROP": "Est-ce que cela peut être cultivé comme plante de couverture\u00a0?", + "COVER_CROP": "Est-ce que cela peut être cultivé comme plante de couverture ?", "CREATE_MANAGEMENT_PLANS": "Créer des plans de culture", "CROP_CATALOGUE": "Catalogue des cultures", "CROP_GROUP": "Recadrer le groupe", @@ -343,7 +389,7 @@ }, "FILTER_TITLE": "Filtre du catalogue de cultures", "GENUS": "Genre", - "HERE_YOU_CAN": "Ici, vous pouvez\u00a0:", + "HERE_YOU_CAN": "Ici, vous pouvez :", "LETS_BEGIN": "Commençons", "NEW_CROP_NAME": "Nouveau nom de culture", "NO_RESULTS_FOUND": "Aucun résultat trouvé. Veuillez modifier vos filtres.", @@ -356,18 +402,17 @@ "CROP_DETAIL": { "ADD_PLAN": "Ajouter un plan", "ANNUAL": "Annuel", - "ANNUAL_PERENNIAL": "La culture est-elle annuelle ou vivace\u00a0?", - "COMMERCIAL_AVAILABILITY": "Avez-vous effectué une recherche de disponibilité commerciale\u00a0?", + "ANNUAL_PERENNIAL": "La culture est-elle annuelle ou vivace ?", + "COMMERCIAL_AVAILABILITY": "Avez-vous effectué une recherche de disponibilité commerciale ?", "DETAIL_TAB": "Détails", "EDIT_CROP_DETAIL": "Modifier les détails de la culture", - "GENETICALLY_ENGINEERED": "Cette culture est-elle génétiquement modifiée\u00a0?", + "GENETICALLY_ENGINEERED": "Cette culture est-elle génétiquement modifiée ?", "HS_CODE": "Code SH", "MANAGEMENT_PLANS": "Plans de culture", "MANAGEMENT_TAB": "Plans", - "ORGANIC": "La semence ou la culture est-elle certifiée biologique\u00a0?", - "ORGANIC_COMPLIANCE": "Conformité organique", + "ORGANIC": "La semence ou la culture est-elle certifiée biologique ?", "PERENNIAL": "vivace", - "TREATED": "Les semences de cette culture ont-elles été traitées\u00a0?" + "TREATED": "Les semences de cette culture ont-elles été traitées ?" }, "CROP_MANAGEMENT": { "GERMINATE": "Germination", @@ -378,19 +423,19 @@ "TRANSPLANT": "Repiquage" }, "CROP_STATUS_NON_ORGANIC_MISMATCH_MODAL": { - "SUBTITLE": "Vous avez indiqué que vous alliez planter une culture biologique dans un endroit non biologique. Voulez-vous continuer\u00a0?", - "TITLE": "Vous avez choisi un emplacement non biologique\u00a0!" + "SUBTITLE": "Vous avez indiqué que vous alliez planter une culture biologique dans un endroit non biologique. Voulez-vous continuer ?", + "TITLE": "Vous avez choisi un emplacement non biologique !" }, "CROP_STATUS_ORGANIC_MISMATCH_MODAL": { - "SUBTITLE": "Vous avez indiqué que vous alliez planter une culture non biologique dans un endroit biologique. Voulez-vous commencer\u00a0?", - "TITLE": "Vous avez choisi un emplacement biologique\u00a0!" + "SUBTITLE": "Vous avez indiqué que vous alliez planter une culture non biologique dans un endroit biologique. Voulez-vous commencer ?", + "TITLE": "Vous avez choisi un emplacement biologique !" }, "CROP_VARIETIES": { "ADD_VARIETY": "Ajouter une nouvelle variété", "CROP_VARIETIES": "variété", "RETIRE": { - "CONFIRMATION": "Retirer cette culture la supprimera de votre catalogue de cultures, ainsi que tous ses plans de culture. Voulez-vous continuer\u00a0?", - "RETIRE_CROP_TITLE": "Retirer la culture\u00a0?", + "CONFIRMATION": "Retirer cette culture la supprimera de votre catalogue de cultures, ainsi que tous ses plans de culture. Voulez-vous continuer ?", + "RETIRE_CROP_TITLE": "Retirer la culture ?", "UNABLE_TO_RETIRE": "Vous ne pouvez retirer que des cultures qui n'ont aucun plan de culture actif ou futur. Vous devez terminer ou abandonner ces plans pour, ensuite, retirer cette culture", "UNABLE_TO_RETIRE_TITLE": "Impossible de retirer cette culture" }, @@ -439,8 +484,8 @@ }, "ADD_DOCUMENT": "Ajouter un nouveau document", "ARCHIVE": "Archiver", - "ARCHIVE_DOCUMENT": "Archiver document\u00a0?", - "ARCHIVE_DOCUMENT_TEXT": "L'archivage de ce document le déplacera vers la section archivée de vos documents, mais ne le supprimera pas. Les documents archivés ne seront pas exportés pour vos certifications. Voulez-vous continuer\u00a0?", + "ARCHIVE_DOCUMENT": "Archiver document ?", + "ARCHIVE_DOCUMENT_TEXT": "L'archivage de ce document le déplacera vers la section archivée de vos documents, mais ne le supprimera pas. Les documents archivés ne seront pas exportés pour vos certifications. Voulez-vous continuer ?", "ARCHIVED": "Archivé", "CANCEL": "Annuler", "CANCEL_MODAL": "création de document", @@ -455,7 +500,7 @@ "NOTES_CHAR_LIMIT": "Les notes doivent comporter moins de 10 000 caractères", "SPOTLIGHT": { "CDC": "Quand il faudra générer votre exportation de certification, LiteFarm exportera automatiquement tous les documents de conformité qui sont valides à la date d'exportation que vous spécifiez. Les documents de conformité seront automatiquement archivés à leur expiration.", - "HERE_YOU_CAN": "Ici, vous pouvez\u00a0:", + "HERE_YOU_CAN": "Ici, vous pouvez :", "YOU_CAN_ONE": "Téléchargez les documents que vous souhaitez inclure dans votre export de certification", "YOU_CAN_THREE": "Archiver les documents inutiles", "YOU_CAN_TWO": "Classer et garder un œil sur les dates d'expiration de votre document" @@ -473,12 +518,12 @@ "WATER_SAMPLE_RESULTS": "Résultats des échantillons d'eau" }, "UNARCHIVE": "Désarchiver", - "UNARCHIVE_DOCUMENT": "Désarchiver le document\u00a0?", - "UNARCHIVE_DOCUMENT_TEXT": "L'annulation de l'archivage de ce document remettra celui-ci dans votre liste de documents valides. Les documents valides seront exportés pour vos certifications. Voulez-vous continuer\u00a0?", + "UNARCHIVE_DOCUMENT": "Désarchiver le document ?", + "UNARCHIVE_DOCUMENT_TEXT": "L'annulation de l'archivage de ce document remettra celui-ci dans votre liste de documents valides. Les documents valides seront exportés pour vos certifications. Voulez-vous continuer ?", "VALID": "Valide" }, "ENTER_PASSWORD": { - "FORGOT": "Mot de passe oublié\u00a0?", + "FORGOT": "Mot de passe oublié ?", "HINT": "Indice", "LABEL": "Mot de passe", "ONE_NUMBER": "au moins un numéro", @@ -490,6 +535,13 @@ "LOCATION": "emplacement", "TASK": "tâche" }, + "ERROR_FALLBACK": { + "CONTACT": "Toujours embourbé ? Pas de souci, nous sommes là pour vous sortir de là:", + "MAIN": "Parfois, LiteFarm se perd et a juste besoin d'un peu d'aide. L'une de ces solutions résout généralement le problème:", + "RELOAD": "Rechargez la page", + "SUBTITLE": "Ne vous inquiétez pas, ce n'est pas de votre faute, c'est la nôtre.", + "TITLE": "Oups ! On dirait que cette page s'est égarée!" + }, "EXPENSE": { "ADD_EXPENSE": { "ADD_CUSTOM_EXPENSE": "Ajouter une dépense personnalisée", @@ -539,12 +591,12 @@ "TOTAL_AREA": "Superficie totale" }, "BARN": { - "ANIMALS": "Cette zone est-elle utilisée pour loger des animaux\u00a0?", - "COLD_STORAGE": "Cette grange a-t-elle une chambre froide\u00a0?", + "ANIMALS": "Cette zone est-elle utilisée pour loger des animaux ?", + "COLD_STORAGE": "Cette grange a-t-elle une chambre froide ?", "EDIT_TITLE": "Modifier la grange", "NAME": "Nom de la grange", "TITLE": "Ajouter une grange", - "WASH_PACK": "Cette grange a-t-elle une station de lavage et d'emballage\u00a0?" + "WASH_PACK": "Cette grange a-t-elle une station de lavage et d'emballage ?" }, "BUFFER_ZONE": { "EDIT_TITLE": "Modifier la zone tampon", @@ -556,8 +608,8 @@ "DOWNLOAD_FILE": { "DEFAULT": "Quelque chose a mal tourné. Veuillez contacter support@litefarm.org pour obtenir de l'aide.", "PARTIAL_SUCCESS_BOTTOM_TEXT": "Ils devraient maintenant être visibles sur votre carte de l'exploitation. Ces capteurs seront ignorés dans les téléchargements futurs\n\n", - "PARTIAL_SUCCESS_TOP_TEXT": "Les capteurs suivants dans votre fichier ont été téléchargés avec succès ou existent déjà sur votre exploitation\u00a0:\n\n", - "ROW": "[Ligne\u00a0: {{ row }}][Colonne\u00a0: {{ column }}] {{- errorMessage }} {{ value }}\n", + "PARTIAL_SUCCESS_TOP_TEXT": "Les capteurs suivants dans votre fichier ont été téléchargés avec succès ou existent déjà sur votre exploitation :\n\n", + "ROW": "[Ligne : {{ row }}][Colonne : {{ column }}] {{- errorMessage }} {{ value }}\n", "SOME_ERRORS": "Malheureusement, des erreurs sont survenues avec votre téléchargement:\n\n" }, "DOWNLOAD_TEMPLATE_LINK_MESSAGE": "Cliquez ici pour télécharger le modèle", @@ -595,7 +647,7 @@ "SENSOR_LONGITUDE": "La valeur de longitude n'est pas valide, elle doit être comprise entre -180 et 180 et avec moins de 10 décimales.", "SENSOR_MODEL": "Nom de modèle non valide, il doit être compris entre 1 et 100 caractères.", "SENSOR_NAME": "Nom de capteur non valide, if doit être compris entre 1 et 100 caractères.", - "SENSOR_READING_TYPES": "Type de données relevées non valide détecté\u00a0: {{ reading_types }}. Les valeurs valides sont\u00a0: teneur_en_eau_du_sol, potentiel_hydrique_du_sol, température." + "SENSOR_READING_TYPES": "Type de données relevées non valide détecté : {{ reading_types }}. Les valeurs valides sont : teneur_en_eau_du_sol, potentiel_hydrique_du_sol, température." } }, "BULK_UPLOAD_TRANSITION": { @@ -609,7 +661,7 @@ }, "CONFIRM_RETIRE": { "BODY": "Retirer cet emplacement le supprimera de la carte de l'exploitation.", - "TITLE": "Retirer l'emplacement\u00a0?" + "TITLE": "Retirer l'emplacement ?" }, "DRAWING_MANAGER": { "REDRAW": "Redessiner", @@ -617,7 +669,7 @@ "ZERO_LENGTH_DETECTED": "Ligne sans longueur détectée. Veuillez dessiner à nouveau." }, "EXPORT_MODAL": { - "BODY": "Comment voulez-vous exporter la carte de votre exploitation\u00a0?", + "BODY": "Comment voulez-vous exporter la carte de votre exploitation ?", "DOWNLOAD": "Télécharger", "EMAIL_TO_ME": "Envoyez-moi un courriel", "EMAILING": "Envoi en cours", @@ -632,13 +684,13 @@ "EDIT_TITLE": "Modifier la clôture", "LENGTH": "Longueur totale", "NAME": "Nom de la clôture", - "PRESSURE_TREATED": "Cette clôture est-elle traitée sous pression\u00a0?", + "PRESSURE_TREATED": "Cette clôture est-elle traitée sous pression ?", "TITLE": "Ajouter une clôture" }, "FIELD": { "DATE": "Fin de période de transition", "EDIT_TITLE": "Modifier le champ", - "FIELD_TYPE": "De quel type de champ s'agit-il\u00a0?", + "FIELD_TYPE": "De quel type de champ s'agit-il ?", "NAME": "Nom du champ", "NON_ORGANIC": "Non-bio", "ORGANIC": "Biologique", @@ -648,7 +700,7 @@ "GARDEN": { "DATE": "Fin de période de transition", "EDIT_TITLE": "Modifier le jardin", - "GARDEN_TYPE": "Quel type de jardin est-ce\u00a0?", + "GARDEN_TYPE": "Quel type de jardin est-ce ?", "NAME": "Nom du jardin", "NON_ORGANIC": "Non-bio", "ORGANIC": "Biologique", @@ -661,32 +713,35 @@ "TITLE": "Ajouter un portail" }, "GREENHOUSE": { - "CO2_ENRICHMENT": "Y a-t-il un enrichissement en CO₂\u00a0?", + "CO2_ENRICHMENT": "Y a-t-il un enrichissement en CO₂ ?", "DATE": "Fin de période de transition", "EDIT_TITLE": "Modifier la serre", - "GREENHOUSE_HEATED": "La serre est-elle chauffée\u00a0?", - "GREENHOUSE_TYPE": "De quel type de serre s'agit-il\u00a0?", + "GREENHOUSE_HEATED": "La serre est-elle chauffée ?", + "GREENHOUSE_TYPE": "De quel type de serre s'agit-il ?", "NAME": "Nom de la serre", "NON_ORGANIC": "Non-bio", "ORGANIC": "Biologique", - "SUPPLEMENTAL_LIGHTING": "Y a-t-il un éclairage supplémentaire\u00a0?", + "SUPPLEMENTAL_LIGHTING": "Y a-t-il un éclairage supplémentaire ?", "TITLE": "Ajouter une serre", "TRANSITIONING": "Transition" }, "LINE_DETAILS": { - "BUFFER_TITLE": "Quelle est la largeur\u00a0?", + "BUFFER_TITLE": "Quelle est la largeur ?", "BUFFER_ZONE_WIDTH": "Largeur de la zone tampon", "RIPARIAN_BUFFER": "Tampon riverain", "WATERCOURSE": "Flux d'eau", - "WATERCOURSE_TITLE": "Quelles sont les largeurs suivantes\u00a0?" + "WATERCOURSE_TITLE": "Quelles sont les largeurs suivantes ?" }, "LOCATION_CREATION_FLOW": "création d'emplacement", "MAP_FILTER": { "ADD_TITLE": "Ajouter à votre carte", "AREAS": "Zones", "BARN": "Grange", + "BUFFER_ZONE": "Zone tampon", "BZ": "Zone tampon", "CA": "Espace de cérémonie", + "CEREMONIAL_AREA": "Espace de cérémonie", + "FARM_SITE_BOUNDARY": "Limite du site agricole", "FENCE": "Clôture", "FIELD": "Champ", "FSB": "Limite du site agricole", @@ -697,6 +752,7 @@ "LABEL": "Étiquettes", "LINES": "Lignes", "NA": "Espace naturel", + "NATURAL_AREA": "Espace naturel", "POINTS": "Points", "RESIDENCE": "Résidence", "SATELLITE": "Image satellite", @@ -704,6 +760,7 @@ "SHOW_ALL": "Afficher tout", "SURFACE_WATER": "Eaux de surface", "TITLE": "Filtrer votre carte", + "WATER_VALVE": "Vanne à eau", "WATERCOURSE": "Flux d'eau", "WV": "Vanne à eau" }, @@ -725,11 +782,11 @@ "EXPORT_TITLE": "Exportez votre carte", "FILTER": "Modifier les emplacements que vous voyez sur votre carte", "FILTER_TITLE": "Filtrez votre carte", - "HERE_YOU_CAN": "Ici, vous pouvez\u00a0:" + "HERE_YOU_CAN": "Ici, vous pouvez :" }, "SURFACE_WATER": { "EDIT_TITLE": "Modifier l'eau de surface", - "IRRIGATION": "Cette zone est-elle utilisée pour l'irrigation\u00a0?", + "IRRIGATION": "Cette zone est-elle utilisée pour l'irrigation ?", "NAME": "Nom de l'eau de surface", "TITLE": "Ajouter l'eau de surface" }, @@ -777,12 +834,12 @@ "RAIN_WATER": "Eau de pluie", "SURFACE_WATER": "Eau de surface", "TITLE": "Ajouter une vanne", - "WATER_VALVE_TYPE": "Quelle est la source de l'eau\u00a0?" + "WATER_VALVE_TYPE": "Quelle est la source de l'eau ?" }, "WATERCOURSE": { "BUFFER": "Tampon rivarain", "EDIT_TITLE": "Modifier le cours d'eau", - "IRRIGATION": "Cette zone est-elle utilisée pour l'irrigation\u00a0?", + "IRRIGATION": "Cette zone est-elle utilisée pour l'irrigation ?", "LENGTH": "Longueur totale", "NAME": "Nom du cours d'eau", "TITLE": "Ajouter un cours d'eau", @@ -838,6 +895,7 @@ }, "REPORT": { "DATES": "Dates", + "FILE_TITLE": "Rapport financier", "SETTINGS": "Exporter les paramètres", "TRANSACTION": "Transaction", "TRANSACTIONS": "Transactions" @@ -899,7 +957,6 @@ "MAMMALS": "Mammifères", "PLANTS": "Plantes", "SPECIES_COUNT_one": "{{count}} espèce", - "SPECIES_COUNT_many": "{{count}} espèces", "SPECIES_COUNT_other": "{{count}} espèces", "TITLE": "Biodiversité" }, @@ -913,12 +970,10 @@ }, "PRICES": { "INFO": "Nous vous montrons la tendance de vos prix de vente pour chacun de vos produits, et la comparons aux prix de ventes des mêmes produits rapportés par d’autres exploitations proches de vous qui font partie du réseau LiteFarm.", - "NEARBY_FARMS": "Le prix moyen du réseau est basé sur les prix de {{count}} autres exploitations dans votre région", "NEARBY_FARMS_one": "Le prix moyen du réseau est basé sur le prix de {{count}} autre exploitation dans votre région", - "NEARBY_FARMS_many": "Le prix moyen du réseau est basé sur les prix de {{count}} autres exploitations dans votre région", "NEARBY_FARMS_other": "Le prix moyen du réseau est basé sur les prix de {{count}} autres exploitations dans votre région", "NETWORK_PRICE": "Prix moyen du réseau", - "NO_ADDRESS": "Vous n'avez pas précisé d'adresse. Veuillez l'ajouter à votre profil pour obtenir des informations sur les prix à proximité\u00a0!", + "NO_ADDRESS": "Vous n'avez pas précisé d'adresse. Veuillez l'ajouter à votre profil pour obtenir des informations sur les prix à proximité !", "OWN_PRICE": "Votre propre prix", "PERCENT_OF_MARKET": "{{percentage}} % du marché", "SALES_FROM_DISTANCE_AWAY": "Ventes dans un rayon de {{distance}} {{unit}}", @@ -936,7 +991,7 @@ }, "INTRODUCE_MAP": { "BODY": "La carte de l'exploitation intègre de nouvelles fonctionnalités. Elle se trouve maintenant dans le menu Mon exploitation.", - "TITLE": "La carte de l'exploitation a été mise à jour\u00a0!" + "TITLE": "La carte de l'exploitation a été mise à jour !" }, "INVITATION": { "BIRTH_YEAR": "Année de naissance", @@ -954,7 +1009,7 @@ "INVITE_SIGN_UP": { "ERROR0": "Vous aurez besoin que l'utilisateur", "ERROR1": "accepte cette invitation à l'exploitation.", - "HOW_TO_CREATE": "Comment voulez-vous créer votre nouveau compte\u00a0?", + "HOW_TO_CREATE": "Comment voulez-vous créer votre nouveau compte ?", "LITEFARM_ACCOUNT": "Créer un compte LiteFarm", "SIGN_IN_WITH": "Connectez-vous avec", "TITLE": "Créez votre compte" @@ -981,20 +1036,20 @@ "TITLE": "Inviter un utilisateur", "WAGE": "Salaire", "WAGE_ERROR": "Le salaire doit être un nombre décimal valide et non négatif", - "WAGE_RANGE_ERROR": "Le salaire doit être un nombre positif inférieur à 999\u00a0999\u00a0999" + "WAGE_RANGE_ERROR": "Le salaire doit être un nombre positif inférieur à 999 999 999" }, "JOIN_FARM_SUCCESS": { - "IMPORTANT_THINGS": "Laissez-nous vous montrer quelques choses importantes\u00a0!", + "IMPORTANT_THINGS": "Laissez-nous vous montrer quelques choses importantes !", "SUCCESSFULLY_JOINED": "Vous avez rejoint l'exploitation avec succès" }, "LOCATION_CREATION": { "CREATE_BUTTON": "Créer l'emplacement", - "CROP_PLAN_BODY": "Vous avez besoin d’un champ, d’un jardin, d’une serre ou d’une zone tampon avant de pouvoir terminer ce plan de culture. Voulez-vous en créer un maintenant\u00a0?", + "CROP_PLAN_BODY": "Vous avez besoin d’un champ, d’un jardin, d’une serre ou d’une zone tampon avant de pouvoir terminer ce plan de culture. Voulez-vous en créer un maintenant ?", "GO_BACK_BUTTON": "Retour", - "TASK_BODY": "Vous avez besoin d’au moins un emplacement avant de pouvoir créer une tâche. Voulez-vous créer un emplacement maintenant\u00a0?", + "TASK_BODY": "Vous avez besoin d’au moins un emplacement avant de pouvoir créer une tâche. Voulez-vous créer un emplacement maintenant ?", "TASK_BODY_WORKER": "Vous avez besoin que la direction crée au moins un emplacement avant de pouvoir créer une tâche.", "TASK_TITLE": "Aucun emplacement pour les tâches", - "TITLE": "Aucun emplacement trouvé\u00a0!" + "TITLE": "Aucun emplacement trouvé !" }, "LOCATION_CROPS": { "ACTIVE_CROPS": "Cultures actives", @@ -1005,7 +1060,7 @@ }, "LOG_COMMON": { "ADD_A_LOG": "Ajouter un journal", - "DELETE_CONFIRMATION": "Voulez-vous vraiment supprimer ce journal\u00a0?", + "DELETE_CONFIRMATION": "Voulez-vous vraiment supprimer ce journal ?", "EDIT_A_LOG": "Modifier un journal", "FROM": "Du", "LOCATION": "Emplacement", @@ -1023,9 +1078,9 @@ "CROP": "Culture", "CROP_PLACEHOLDER": "Sélectionnez une culture", "CUSTOM_HARVEST_USE": "Utilisations de cultures personnalisées", - "HARVEST_ALLOCATION_SUBTITLE": "Environ quelle quantité de la récolte sera utilisée pour chaque objectif\u00a0?", + "HARVEST_ALLOCATION_SUBTITLE": "Environ quelle quantité de la récolte sera utilisée pour chaque objectif ?", "HARVEST_ALLOCATION_SUBTITLE_TWO": "Montant à allouer", - "HARVEST_USE_TYPE_SUBTITLE": "Comment la récolte sera-t-elle utilisée\u00a0?", + "HARVEST_USE_TYPE_SUBTITLE": "Comment la récolte sera-t-elle utilisée ?", "QUANTITY_ERROR": "La quantité doit comporter jusqu'à 2 décimales", "TITLE": "Journal de récolte" }, @@ -1041,7 +1096,7 @@ "CANT_ABANDON_CONCURRENT_USER": "Un autre utilisateur a terminé ce plan. Veuillez revenir en arrière pour obtenir le statut le plus récent." }, "ABANDON_MANAGEMENT_PLAN_CONTENT": "Abandonner ce plan de culture abandonnera toutes les tâches incomplètes qui lui sont associées et le supprimera de votre carte de l'exploitation.", - "ABANDON_MANAGEMENT_PLAN_TITLE": "Abandonner le plan de culture\u00a0?", + "ABANDON_MANAGEMENT_PLAN_TITLE": "Abandonner le plan de culture ?", "ADD_MANAGEMENT_PLAN": "Ajouter un plan de culture", "AGE": "Âge", "AS_COVER_CROP": "Culture de couverture", @@ -1070,20 +1125,20 @@ "SOMETHING_ELSE": "Autre problème", "WEATHER": "Météo" }, - "WHAT_HAPPENED": "Qu'est-ce qui est arrivé\u00a0?" + "WHAT_HAPPENED": "Qu'est-ce qui est arrivé ?" }, "COMPLETION_NOTES": "Notes d'achèvement", "CONTAINER": "Conteneur", - "CONTAINER_OR_IN_GROUND": "Plantez-vous dans un conteneur ou en pleine terre\u00a0?", + "CONTAINER_OR_IN_GROUND": "Plantez-vous dans un conteneur ou en pleine terre ?", "CONTAINER_TYPE": "Type de conteneur", "COVER_INFO": "Sélectionner une culture de couverture créera une tâche de travail sur le terrain pour terminer la culture de couverture à la fin de la saison. Sélectionner pour la récolte créera une tâche de récolte à la place.", - "COVER_OR_HARVEST": "S'agit-il d'une culture de couverture ou pour la récolte\u00a0?", + "COVER_OR_HARVEST": "S'agit-il d'une culture de couverture ou pour la récolte ?", "CROP_PLAN_REPEAT": "Ce plan de culture se répète", "CROP_PLAN_REPEAT_SUBTEXT": "Cliquer sur 'Sauvegarder' créera un plan de culture unique. Il vous sera ensuite demandé de décrire comment le répéter.", - "DAYS_FROM_PLANTING": "Jours entre la plantation et\u00a0:", - "DAYS_FROM_SEEDING": "Jours entre le semis et\u00a0:", - "DAYS_TO_HARVEST": "Jours entre la plantation et la récolte\u00a0:", - "DAYS_TO_TERMINATION": "Jours entre la plantation et la clôture\u00a0:", + "DAYS_FROM_PLANTING": "Jours entre la plantation et :", + "DAYS_FROM_SEEDING": "Jours entre le semis et :", + "DAYS_TO_HARVEST": "Jours entre la plantation et la récolte :", + "DAYS_TO_TERMINATION": "Jours entre la plantation et la clôture :", "DELETE": { "CANT_DELETE_ABANDON": "Abandonner", "CANT_DELETE_ABANDON_INSTEAD": "Voulez-vous plutôt abandonner le plan de culture?", @@ -1096,7 +1151,7 @@ }, "DETAIL_SPOTLIGHT_CONTENTS": "Ici, vous pouvez modifier les détails de votre culture.", "DETAIL_SPOTLIGHT_TITLE": "Détails", - "DO_YOU_WANT_TO_ABANDON_CONTENT": "Voulez-vous abandonner ce plan\u00a0?", + "DO_YOU_WANT_TO_ABANDON_CONTENT": "Voulez-vous abandonner ce plan ?", "DROP_PIN": "Placez le marqueur", "DURATION_TOOLTIP": "Ce sont des valeurs suggérées. Veuillez les ajuster en fonction de vos conditions locales.", "EDITING_PLAN_WILL_NOT_MODIFY": "La modification de ce plan ne modifiera pas les tâches qui lui sont assignées.", @@ -1105,28 +1160,28 @@ "FIRST_MP_SPOTLIGHT": { "BODY_PART1": "LiteFarm a généré quelques tâches basées sur votre plan. Vous pouvez ajouter d'autres tâches ou les attribuer sur cet écran.", "BODY_PART2": "Votre plan deviendra actif une fois que vous aurez terminé une tâche.", - "TITLE": "Félicitations\u00a0! Vous avez établi votre premier plan de culture\u00a0!" + "TITLE": "Félicitations ! Vous avez établi votre premier plan de culture !" }, "FOR_HARVEST": "Pour la récolte", "GERMINATION": "Germination", "HARVEST": "Récolte initiale", - "HARVEST_DATE": "Quand prévoyez-vous votre prochaine récolte\u00a0?", + "HARVEST_DATE": "Quand prévoyez-vous votre prochaine récolte ?", "HARVEST_TO_DATE": "Récolte à ce jour", "HARVEST_TO_DATE_INFO": "La récolte à ce jour est calculée à partir des tâches de récolte terminées pour ce plan.", - "HISTORICAL_CONTAINER_OR_IN_GROUND": "A-t-il été planté dans un conteneur ou dans le sol\u00a0?", + "HISTORICAL_CONTAINER_OR_IN_GROUND": "A-t-il été planté dans un conteneur ou dans le sol ?", "IN_GROUND": "Déjà installée", "INCOMPLETE_TASK_CONTENT": "Ce plan comporte des tâches qui ne sont pas encore terminées. Vous devrez marquer les tâches comme terminées afin de terminer ce plan de culture.", "INCOMPLETE_TASK_TITLE": "Vous avez des tâches incomplètes", "INDIVIDUAL_CONTAINER": "Individuel ou conteneur", - "IS_TRANSPLANT": "Cette culture sera-t-elle repiquée\u00a0?", - "KNOW_HOW_IS_CROP_PLANTED": "Savez-vous comment la culture a été plantée\u00a0?", + "IS_TRANSPLANT": "Cette culture sera-t-elle repiquée ?", + "KNOW_HOW_IS_CROP_PLANTED": "Savez-vous comment la culture a été plantée ?", "LOCATION_SUBTEXT": "Seuls les emplacements pouvant faire pousser des cultures sont affichés.", "MANAGEMENT_PLAN_FLOW": "création du plan de culture", "MANAGEMENT_SPOTLIGHT_1": "Créer de nouveaux plans pour cette culture", "MANAGEMENT_SPOTLIGHT_2": "Afficher et modifier les plans pour cette culture", "MANAGEMENT_SPOTLIGHT_3": "Créer et attribuer des tâches", "MANAGEMENT_SPOTLIGHT_TITLE": "Plans", - "NEXT_HARVEST": "Quand prévoyez-vous votre prochaine récolte\u00a0?", + "NEXT_HARVEST": "Quand prévoyez-vous votre prochaine récolte ?", "NOTES_CHAR_LIMIT": "Les notes doivent comporter moins de 10 000 caractères", "NUMBER_OF_CONTAINER": "# de conteneurs", "PENDING_TASK": "Tâches en attente", @@ -1134,13 +1189,13 @@ "PLAN_NAME": "Nom du plan de culture", "PLAN_NOTES": "Notes de plan", "PLANT_SPACING": "Espacement des plantes", - "PLANTED_ALREADY": "Cette culture est-elle déjà en terre, ou allez-vous l'installer\u00a0?", + "PLANTED_ALREADY": "Cette culture est-elle déjà en terre, ou allez-vous l'installer ?", "PLANTING": "à installer", - "PLANTING_DATE": "Quelle est votre date de plantation\u00a0?", - "PLANTING_DATE_INFO": "Date de semis basée sur l'âge de la culture\u00a0: {{seed_date}}", + "PLANTING_DATE": "Quelle est votre date de plantation ?", + "PLANTING_DATE_INFO": "Date de semis basée sur l'âge de la culture : {{seed_date}}", "PLANTING_DATE_LABEL": "Date de plantation", "PLANTING_DEPTH": "Profondeur de plantation", - "PLANTING_METHOD": "Quelle est votre méthode de plantation\u00a0?", + "PLANTING_METHOD": "Quelle est votre méthode de plantation ?", "PLANTING_METHOD_TOOLTIP": "Sélectionner la bonne méthode de plantation aidera LiteFarm à estimer plus précisément la quantité de graines nécessaires, le rendement et d'autres informations utiles.", "PLANTING_NOTE": "Notes de plantation", "PLANTING_SOIL": "Terre de plantation à utiliser", @@ -1149,27 +1204,27 @@ "REMOVE_PIN": "Supprimer l'épingle", "REPEATED_MP_SPOTLIGHT": { "BODY": "Cliquez ici pour voir tous les plans de culture de ce groupe. Modifier un plan de culture individuel ne changera rien aux autres du groupe.", - "TITLE": "Bravo\u00a0! Vous avez créé votre premier plan de culture répété\u00a0!" + "TITLE": "Bravo ! Vous avez créé votre premier plan de culture répété !" }, "ROW_METHOD": { - "HISTORICAL_SAME_LENGTH": "Les lignes étaient-elles toutes de la même longueur\u00a0?", + "HISTORICAL_SAME_LENGTH": "Les lignes étaient-elles toutes de la même longueur ?", "LENGTH_OF_ROW": "Longueur de la ligne", "NUMBER_OF_ROWS": "# de lignes", - "SAME_LENGTH": "Vos lignes sont-elles toutes de la même longueur\u00a0?", + "SAME_LENGTH": "Vos lignes sont-elles toutes de la même longueur ?", "TOTAL_LENGTH": "Longueur totale des lignes" }, "ROWS": "Lignes", - "SEED_DATE": "Quelle est votre date de semis\u00a0?", - "SEED_OR_SEEDLING": "Quel est votre point de départ pour cette culture\u00a0?", + "SEED_DATE": "Quelle est votre date de semis ?", + "SEED_OR_SEEDLING": "Quel est votre point de départ pour cette culture ?", "SEEDING_DATE": "Date de semis", "SEEDLING": "Plant ou matériel de plantation", - "SEEDLING_AGE": "Quel âge a le plant ou le matériel de plantation\u00a0?", + "SEEDLING_AGE": "Quel âge a le plant ou le matériel de plantation ?", "SEEDLING_AGE_INFO": "L'âge approximatif aidera LiteFarm à estimer les dates de récolte des semis. Vous pouvez ignorer cette question pour d'autres types de matériel de plantation.", "SELECT_A_PLANTING_LOCATION": "Sélectionnez un emplacement de plantation", "SELECT_A_SEEDING_LOCATION": "Sélectionnez un emplacement pour le semis", "SELECT_CURRENT_LOCATION": "Sélectionnez l'emplacement actuel de la culture", "SELECTED_STARTING_LOCATION": "Sélectionnez toujours ceci comme emplacement de départ pour les cultures qui seront transplantées", - "SPOTLIGHT_HERE_YOU_CAN": "Ici, vous pouvez\u00a0:", + "SPOTLIGHT_HERE_YOU_CAN": "Ici, vous pouvez :", "STARTED": "Commencé", "STATUS": { "ABANDONED": "Abandonné", @@ -1179,11 +1234,11 @@ }, "SUPPLIER": "Fournisseur", "TERMINATION": "clôture", - "TERMINATION_DATE": "Quand voulez vous clôturer cette culture\u00a0?", + "TERMINATION_DATE": "Quand voulez vous clôturer cette culture ?", "TOTAL_PLANTS": "# de plantes", "TRANSPLANT": "Repiquage", - "TRANSPLANT_DATE": "Quelle est la date de repiquage\u00a0?", - "TRANSPLANT_LOCATION": "Où sera placée la culture après repiquage\u00a0?", + "TRANSPLANT_DATE": "Quelle est la date de repiquage ?", + "TRANSPLANT_LOCATION": "Où sera placée la culture après repiquage ?", "TRANSPLANT_SPOTLIGHT": { "BODY": { "PLANTED": "repiquée", @@ -1196,12 +1251,12 @@ "TEXT": "emplacement de {{fill}}" } }, - "WHAT_IS_AGE": "Quel est l'âge de la récolte\u00a0?", - "WHAT_WAS_PLANTING_METHOD": "Quelle était la méthode de plantation\u00a0?", + "WHAT_IS_AGE": "Quel est l'âge de la récolte ?", + "WHAT_WAS_PLANTING_METHOD": "Quelle était la méthode de plantation ?", "WHAT_WAS_PLANTING_METHOD_INFO": "Sélectionner la bonne méthode de plantation aidera LiteFarm à estimer plus précisément la quantité de graines nécessaires, le rendement et d'autres informations utiles.", - "WHERE_START_LOCATION": "Où est votre position de départ\u00a0?", - "WHERE_TRANSPLANT_LOCATION": "Où allez-vous transplanter\u00a0?", - "WILD_CROP": "Récoltez-vous une culture sauvage\u00a0?" + "WHERE_START_LOCATION": "Où est votre position de départ ?", + "WHERE_TRANSPLANT_LOCATION": "Où allez-vous transplanter ?", + "WILD_CROP": "Récoltez-vous une culture sauvage ?" }, "MENU": { "ACTUAL_REVENUES": "Chiffres d'affaires réels", @@ -1241,7 +1296,7 @@ "SEE_UPDATES": "Voir les mises à jour importantes", "TASK_TITLE": "Ceci est votre centre de notification", "TIPS": "Des conseils utiles", - "YOU_CAN": "Ici vous pouvez\u00a0:", + "YOU_CAN": "Ici vous pouvez :", "YOU_WILL_FIND": "Vous trouverez ici:" } }, @@ -1260,7 +1315,7 @@ "BODY": "Le chargement de votre capteur a été terminé avec succès", "TITLE": "Chargement du capteur terminé" }, - "TAKE_ME_THERE": "Allons-y\u00a0!", + "TAKE_ME_THERE": "Allons-y !", "TASK_ABANDONED": { "BODY": "Une tâche de {{taskType}} qui vous était assignée a été abandonnée par {{abandoner}}.", "TITLE": "Tâche abandonnée" @@ -1296,8 +1351,8 @@ } }, "OUTRO": { - "ALL_DONE": "Super\u00a0! Vous avez terminé. Prêt à vous salir les mains\u00a0?", - "IMPORTANT_THINGS": "Et enfin, laissez-nous vous montrer quelques choses importantes\u00a0!" + "ALL_DONE": "Super ! Vous avez terminé. Prêt à vous salir les mains ?", + "IMPORTANT_THINGS": "Et enfin, laissez-nous vous montrer quelques choses importantes !" }, "PASSWORD_RESET": { "BUTTON": "Renvoyer le lien", @@ -1311,12 +1366,12 @@ "TITLE": "Lien envoyé" }, "PASSWORD_RESET_SUCCESS_MODAL": { - "BUTTON": "Super\u00a0!", + "BUTTON": "Super !", "DESCRIPTION": "Votre mot de passe a été mis à jour. Nous vous redirigerons vers vos exploitations en 10 secondes...", - "TITLE": "Succès\u00a0!" + "TITLE": "Succès !" }, "PLAN_GUIDANCE": { - "ADDITIONAL_GUIDANCE": "Voulez-vous fournir des conseils supplémentaires pour cette plantation\u00a0?", + "ADDITIONAL_GUIDANCE": "Voulez-vous fournir des conseils supplémentaires pour cette plantation ?", "BED": "Planche", "BEDS": "planches", "NOTES": "Notes de plantation", @@ -1374,7 +1429,7 @@ }, "FARM_TAB": "Exploitation", "PEOPLE": { - "DO_YOU_WANT_TO_REMOVE": "Voulez-vous enlever cet utilisateur de votre exploitation\u00a0?", + "DO_YOU_WANT_TO_REMOVE": "Voulez-vous enlever cet utilisateur de votre exploitation ?", "INVALID_REVOKE_ACCESS": "Impossible de révoquer l’accès", "INVITE_USER": "Inviter un utilisateur", "LAST_ADMIN_ERROR": "Nous ne pouvons pas révoquer l’accès à cet utilisateur parce qu’il est le dernier gestionnaire de cette exploitation. Si vous souhaitez supprimer votre exploitation, créez un billet en cliquant <1>ici.", @@ -1403,9 +1458,9 @@ "CLEAR_ALL": "Supprimer tout" }, "RELEASE": { - "BETTER": "MISSING", - "LITEFARM_UPDATED": "LiteFarm v{{version}} est maintenant disponible\u00a0!", - "NOTES": "MISSING" + "BETTER": "LiteFarm vient de s'améliorer!", + "LITEFARM_UPDATED": "LiteFarm v{{version}} est maintenant disponible !", + "NOTES": "Notes de version" }, "REPEAT_PLAN": { "AFTER": "Après", @@ -1479,8 +1534,8 @@ "FARM_EO": "Agent de vulgarisation", "FARM_MANAGER": "Gestionnaire de l'exploitation", "FARM_OWNER": "Propriétaire de l'exploitation", - "IS_OWNER_OPERATED": "cette exploitation est-elle gérée par le propriétaire \u00a0?", - "TITLE": "Quel est votre rôle à l'exploitation\u00a0?" + "IS_OWNER_OPERATED": "cette exploitation est-elle gérée par le propriétaire  ?", + "TITLE": "Quel est votre rôle à l'exploitation ?" }, "SALE": { "ADD_SALE": { @@ -1585,8 +1640,8 @@ "LONGTITUDE": "Longitude", "MINUTES_AGO": "il y a {{time}} minute(s)", "MODAL": { - "BODY": "Changer les types de données relevées par ce capteur va modifier les données que LiteFarm peut afficher. Voulez-vous continuer\u00a0?", - "TITLE": "Changer les types de capteurs\u00a0?" + "BODY": "Changer les types de données relevées par ce capteur va modifier les données que LiteFarm peut afficher. Voulez-vous continuer ?", + "TITLE": "Changer les types de capteurs ?" }, "MODEL": "Modèle", "MODEL_HELPTEXT": "Cet identifiant est utilisé pour identifier le capteur de façon unique dans d’autres systèmes intégrés et ne peut pas être modifié. S’il n’est plus utilisé, essayez de retirer ce capteur et d’en ajouter un nouveau.", @@ -1605,18 +1660,18 @@ "TEMPERATURE": "Température actuelle du sol" }, "RETIRE": { - "BODY": "La mise hors service de ce capteur l’enlèvera de votre exploitation ainsi que de toute connexion avec le fournisseur de capteurs. Voulez-vous continuer\u00a0?", + "BODY": "La mise hors service de ce capteur l’enlèvera de votre exploitation ainsi que de toute connexion avec le fournisseur de capteurs. Voulez-vous continuer ?", "CANCEL": "Annuler", "RETIRE": "Retirer", "RETIRE_FAILURE": "Il y a eu une erreur lors du retrait de ce capteur", "RETIRE_SUCCESS": "Retrait du capteur réussi", - "TITLE": "Retirer le capteur\u00a0?" + "TITLE": "Retirer le capteur ?" }, "SECONDS_AGO": "il y a {{time}} seconde(s)", "SENSOR_FORECAST": { - "HIGH_AND_LOW_TEMPERATURE": "Haute et basse température\u00a0: {{high}}{{unit}} / {{low}}{{unit}}", + "HIGH_AND_LOW_TEMPERATURE": "Haute et basse température : {{high}}{{unit}} / {{low}}{{unit}}", "TITLE": "La météo d'aujourd'hui", - "WEATHER_STATION": "Station météorologique\u00a0: {{weatherStationLocation}}" + "WEATHER_STATION": "Station météorologique : {{weatherStationLocation}}" }, "SENSOR_NAME": "Nom du capteur", "SENSOR_READING_CHART_SPOTLIGHT": { @@ -1657,12 +1712,12 @@ "EXPIRED_INVITATION_LINK_ERROR": "Invitation invalide ou expirée", "GOOGLE_BUTTON": "CONTINUER AVEC GOOGLE", "INVITED_ERROR": "L'invitation à l'exploitation vous a été envoyée par e-mail, veuillez vérifier votre boîte de réception et votre dossier spam.", - "LITEFARM_UPDATED": "LiteFarm v3.5 est maintenant disponible\u00a0!", + "LITEFARM_UPDATED": "LiteFarm v3.5 est maintenant disponible !", "PASSWORD_ERROR": "Mot de passe incorrect", "SIGN_IN": "Se Connecter", "SSO_ERROR": "Veuillez connecter par cliquer le bouton Google au-dessus", "USED_INVITATION_LINK_ERROR": "Cette invitation a déjà été utilisée, veuillez vous connecter pour accéder à cette exploitation", - "WELCOME_BACK": "Content de vous revoir\u00a0!", + "WELCOME_BACK": "Content de vous revoir !", "WRONG_BROWSER": "LiteFarm n'est pas optimisé pour ce navigateur de Web.", "WRONG_BROWSER_BOTTOM": "Veuillez reconnecter avec Chrome." }, @@ -1685,8 +1740,8 @@ "TITLE": "{{certification}} certification de {{certifier}}" }, "SWITCH_OUTRO": { - "BUTTON": "Allons-y\u00a0!", - "DESCRIPTION_BOTTOM": "Direction\u00a0: ", + "BUTTON": "Allons-y !", + "DESCRIPTION_BOTTOM": "Direction : ", "DESCRIPTION_TOP": "La grange est bien fermée.", "TITLE": "Changement d'exploitation" }, @@ -1723,12 +1778,12 @@ }, "REASON_FOR_ABANDONMENT": "Raison de l'abandon", "TITLE": "Abandonner la tâche", - "WHAT_HAPPENED": "Que s'est-il passé\u00a0?", - "WHEN": "Quand la tâche a-t-elle été abandonnée\u00a0?", - "WHICH_DATE": "À quelle date\u00a0?" + "WHAT_HAPPENED": "Que s'est-il passé ?", + "WHEN": "Quand la tâche a-t-elle été abandonnée ?", + "WHICH_DATE": "À quelle date ?" }, "ABANDON_TASK": "Abandonner cette tâche", - "ABANDON_TASK_DURATION": "Des travaux ont-ils été réalisés pour cette tâche\u00a0?", + "ABANDON_TASK_DURATION": "Des travaux ont-ils été réalisés pour cette tâche ?", "ABANDON_TASK_HELPTEXT": "Les travaux effectués pour cette tâche abandonnée seront intégrés aux coûts de main-d’œuvre. Si aucun travail n’a été effectué, veuillez plutôt le sélectionner.", "ABANDONMENT_DETAILS": "Détails de l'abandon de tâche", "ADD_CUSTOM_HARVEST_USE": "Créer une utilisation de récolte personnalisée", @@ -1742,12 +1797,12 @@ }, "COMPLETE": { "DATE": "Date d'achèvement", - "WHEN": "Quand la tâche a-t-elle été terminée\u00a0?" + "WHEN": "Quand la tâche a-t-elle été terminée ?" }, - "COMPLETE_HARVEST_QUANTITY": "Quelle quantité a été récoltée\u00a0?", + "COMPLETE_HARVEST_QUANTITY": "Quelle quantité a été récoltée ?", "COMPLETE_TASK": "Terminer la tâche", - "COMPLETE_TASK_CHANGES": "Avez-vous dû apporter des modifications à cette tâche\u00a0?", - "COMPLETE_TASK_DURATION": "Combien de temps la tâche a-t-elle pris pour se terminer\u00a0?", + "COMPLETE_TASK_CHANGES": "Avez-vous dû apporter des modifications à cette tâche ?", + "COMPLETE_TASK_DURATION": "Combien de temps la tâche a-t-elle pris pour se terminer ?", "COMPLETE_TASK_FLOW": "achèvement de la tâche", "COMPLETION_DETAILS": "Détails de la tâche", "COMPLETION_NOTES": "Notes sur la tâche", @@ -1760,18 +1815,18 @@ "CURRENT": "Courant", "DELETE": { "CANT_DELETE_ABANDON": "Aller au plan de culture", - "CANT_DELETE_ABANDON_INSTEAD": "Voulez-vous plutôt abandonner le plan de culture\u00a0?", + "CANT_DELETE_ABANDON_INSTEAD": "Voulez-vous plutôt abandonner le plan de culture ?", "CANT_DELETE_PLANTING_TASK": "Impossible de supprimer une tâche de plantation.", "CONFIRM_DELETION": "Confirmer la suppression", "DELETE_TASK": "Supprimer la tâche", "DELETE_TASK_MESSAGE": "Cette tâche sera supprimée de votre exploitation. Si vous voulez garder une trace de cette tâche, cliquez plutôt sur \"Abandonner\".", - "DELETE_TASK_QUESTION": "Supprimer la tâche\u00a0?", + "DELETE_TASK_QUESTION": "Supprimer la tâche ?", "FAILED": "Echec de la suppression de la tâche", "SUCCESS": "Tâche supprimée avec succès" }, "DESCRIBE_HARVEST_USE": "Décrivez l'utilisation de la récolte", "DETAILS": "Détails", - "DID_YOU_ENJOY": "Avez-vous apprécié cette tâche\u00a0?", + "DID_YOU_ENJOY": "Avez-vous apprécié cette tâche ?", "DUE_DATE": "Date d'échéance", "DURATION": "Durée", "FILTER": { @@ -1791,7 +1846,7 @@ }, "HARVEST_USE": "Usage de la récolte", "HARVEST_USE_ALREADY_EXISTS": "Cet usage de la récolte existe déjà", - "HOW_WILL_HARVEST_BE_USED": "Comment la récolte sera-t-elle utilisée\u00a0?", + "HOW_WILL_HARVEST_BE_USED": "Comment la récolte sera-t-elle utilisée ?", "IRRIGATION_LOCATION": "Sélectionner un emplacement d’irrigation", "LOCATIONS": "Emplacement(s)", "NO_TASKS_TO_DISPLAY": "Il n'y a aucune tâche à afficher.", @@ -1805,6 +1860,7 @@ "SELECT_DATE": "Sélectionnez la date de la tâche", "SELECT_TASK_LOCATIONS": "Sélectionnez le(s) emplacement(s) de la tâche", "SELECT_WILD_CROP": "Cette tâche cible une culture sauvage", + "SOIL_AMENDMENT_LOCATION": "Sélectionnez le(s) emplacement(s) d'amendement du sol", "STATUS": { "ABANDONED": "Abandonnée", "COMPLETED": "Terminée", @@ -1814,7 +1870,6 @@ }, "TASK": "tâche", "TASKS_COUNT_one": "{{count}} tâche", - "TASKS_COUNT_many": "{{count}} tâches", "TASKS_COUNT_other": "{{count}} tâches", "TRANSPLANT": "Repiquage", "TRANSPLANT_LOCATIONS": "Sélectionnez un lieu de repiquage", @@ -1830,14 +1885,14 @@ "VALID_VALUE": "Veuillez entrez une valeur entre 0-" }, "UNKNOWN_RECORD": { - "CANT_FIND": "Oups\u00a0! Nous n'avons pas réussi a trouver cela.", + "CANT_FIND": "Oups ! Nous n'avons pas réussi a trouver cela.", "MAYBE_LATER": "Espérons le trouver plus tard.", "UNKNOWN_RECORD": "Enregistrement inconnu" }, "WAGE": { "ERROR": "Le salaire doit être un nombre décimal valide et non négatif", "HOURLY_WAGE": "Salaire horaire", - "HOURLY_WAGE_RANGE_ERROR": "Le salaire horaire doit être un nombre positif inférieur à 999\u00a0999\u00a0999", + "HOURLY_WAGE_RANGE_ERROR": "Le salaire horaire doit être un nombre positif inférieur à 999 999 999", "HOURLY_WAGE_TOOLTIP": "Les salaires horaires peuvent être établis en sélectionnant une personne à l’onglet Employés sous 'Mon exploitation'." }, "WEATHER": { diff --git a/packages/webapp/public/locales/pt/certifications.json b/packages/webapp/public/locales/pt/certifications.json index ca85b8bbee..1a0d66ff14 100644 --- a/packages/webapp/public/locales/pt/certifications.json +++ b/packages/webapp/public/locales/pt/certifications.json @@ -1,4 +1,4 @@ { - "ORGANIC": "Orgânica por auditoria", + "THIRD_PARTY_ORGANIC": "Orgânica por auditoria", "PGS": "Sistema Participativo de Garantia (SPG)" } diff --git a/packages/webapp/public/locales/pt/common.json b/packages/webapp/public/locales/pt/common.json index 987f572d00..338b083e85 100644 --- a/packages/webapp/public/locales/pt/common.json +++ b/packages/webapp/public/locales/pt/common.json @@ -20,6 +20,8 @@ "DO_NOT_SHOW": "Não mostrar esta mensagem novamente", "EDIT": "Editar", "EDIT_DATE": "Editar data", + "EDITING": "Editando...", + "ENTER_VALUE": "Digite o valor", "EXPORT": "Exportar", "FINISH": "Finalizar", "FROM": "de", @@ -41,6 +43,7 @@ "NOTES": "Notas", "OK": "Ok", "OPTIONAL": "(Opcional)", + "OR": "ou", "OTHER": "Outro", "PAST": "Passado", "PLANNED": "Planejado", diff --git a/packages/webapp/public/locales/pt/crop.json b/packages/webapp/public/locales/pt/crop.json index 0bd3578f7b..3d3a3b2adf 100644 --- a/packages/webapp/public/locales/pt/crop.json +++ b/packages/webapp/public/locales/pt/crop.json @@ -207,6 +207,7 @@ "MULBERRY_FOR_SILKWORMS": "Amora para bicho da seda (todas as variedades)", "MUSHROOMS": "Cogumelos", "MUSTARD": "Mostarda", + "MUSTARD_FOR_SEED": "Mostarda, para semente", "NECTARINE": "Nectarina", "NIGER_SEED": "Guizotia abyssinica", "NUTMEG": "Noz-moscada", @@ -240,6 +241,7 @@ "PEPPER_BLACK": "Pimenta preta", "PEPPER_DRY": "Pimenta, seca", "PERSIMMON": "Caqui", + "PERSIMMON_KAKI": "Caqui", "PIGEON_PEA": "Feijão guandu", "PINEAPPLE": "Abacaxi", "PISTACHIO_NUT": "Pistachio", @@ -338,7 +340,6 @@ "WHEAT": "Trigo", "YAM": "Inhame", "YERBA_MATE": "Erva-mate", - "MUSTARD_FOR_SEED": "Mostarda, para semente", "ABIU": "Abieiro", "ACAI_PALM": "Açaí", "ACHACHA": "Achahairú", diff --git a/packages/webapp/public/locales/pt/crop_nutrients.json b/packages/webapp/public/locales/pt/crop_nutrients.json index ece380e12d..450558466d 100644 --- a/packages/webapp/public/locales/pt/crop_nutrients.json +++ b/packages/webapp/public/locales/pt/crop_nutrients.json @@ -25,4 +25,4 @@ "VITAMIN_B12": "Vitamina B12", "MAX_ROOTING": "Enraizamento máximo", "NUTRIENT_CREDITS": "Créditos de nutriente" -} +} \ No newline at end of file diff --git a/packages/webapp/public/locales/pt/filter.json b/packages/webapp/public/locales/pt/filter.json index cf8889b338..515c145280 100644 --- a/packages/webapp/public/locales/pt/filter.json +++ b/packages/webapp/public/locales/pt/filter.json @@ -23,15 +23,15 @@ "INVOICES": "Faturas" }, "TASKS": { - "ABANDONED": "Abandonada", + "LOCATION": "Localização", + "STATUS": "Situação", + "SUPPLIERS": "Fornecedores", "ACTIVE": "Ativa", + "ABANDONED": "Abandonada", "COMPLETED": "Completa", - "FOR REVIEW": "Para revisão", "LATE": "Atrasada", - "LOCATION": "Localização", "PLANNED": "Planejada", - "STATUS": "Situação", - "SUPPLIERS": "Fornecedores" + "FOR_REVIEW": "Para revisão" }, "FILTER": { "VALID_ON": "Válido em", diff --git a/packages/webapp/public/locales/pt/message.json b/packages/webapp/public/locales/pt/message.json index 6f95373d2e..47bba91724 100644 --- a/packages/webapp/public/locales/pt/message.json +++ b/packages/webapp/public/locales/pt/message.json @@ -121,6 +121,16 @@ "EDIT": "Plano de cultivo atualizado com sucesso" } }, + "PRODUCT": { + "ERROR": { + "CREATE": "Falha ao criar o produto", + "UPDATE": "Falha ao atualizar o produto" + }, + "SUCCESS": { + "CREATE": "Produto criado com sucesso", + "UPDATE": "Produto atualizado com sucesso" + } + }, "REPEAT_PLAN": { "ERROR": { "POST": "Falha ao repetir {{planName}}" diff --git a/packages/webapp/public/locales/pt/translation.json b/packages/webapp/public/locales/pt/translation.json index 2814797f3f..0a690ca12b 100644 --- a/packages/webapp/public/locales/pt/translation.json +++ b/packages/webapp/public/locales/pt/translation.json @@ -14,15 +14,40 @@ "TELL_US_ABOUT_YOUR_FARM": "Conte-nos sobre seu sítio/fazenda" }, "ADD_PRODUCT": { + "ADD_ANOTHER_PRODUCT": "Adicionar outro produto", + "ADDITIONAL_NUTRIENTS": "Nutrientes adicionais", + "AMMONIUM": "Amônio (NH₄)", + "BORON": "Boro (B)", + "BUTTON_WARNING": "Quaisquer alterações afetarão todas as tarefas que envolvem este produto", + "CALCIUM": "Cálcio (Ca)", + "COMPOSITION": "Composição", + "COMPOSITION_ERROR": "Erro: A porcentagem total de N, P, K e nutrientes adicionais não deve exceder 100%. Por favor ajuste seus valores.", + "COPPER": "Cobre (Cu)", + "DRY_FERTILISER": "Sólido", + "DRY_MATTER_CONTENT": "Conteúdo de matéria seca", + "EDIT_PRODUCT_DETAILS": "Editar detalhes do produto", + "FERTILISER_TYPE": "Este produto é sólido ou líquido?", + "LIQUID_FERTILISER": "Líquido", + "MAGNESIUM": "Magnésio (Mg)", + "MANGANESE": "Manganês (Mn)", + "MOISTURE_CONTENT": "Teor de umidade", + "NITRATE": "Nitrato (NO₃)", + "NITROGEN": "Nitrogênio (N)", + "PHOSPHOROUS": "Fósforo (P₂O₅)", + "POTASSIUM": "Potássio (K₂O)", "PRESS_ENTER": "Digite e pressione enter para adicionar...", + "PRODUCT_DETAILS": "Detalhes do produto", "PRODUCT_LABEL": "Produto", - "SUPPLIER_LABEL": "Fornecedor" + "SAVE_PRODUCT": "Salvar produto", + "SULFUR": "Enxofre (S)", + "SUPPLIER_LABEL": "Fornecedor", + "WHAT_YOU_WILL_BE_APPLYING": "O que você vai aplicar?" }, "ADD_TASK": { "ADD_A_CUSTOM_TASK": "Adicione uma tarefa personalizada", "ADD_A_TASK": "Adicione uma tarefa", "ADD_CUSTOM_TASK": "Adicionar tarefa personalizada", - "AFFECT_PLANS": "Esta tarefa influenciará em algum plano", + "AFFECT_PLANS": "Esta tarefa influenciará em algum plano?", "ASSIGN_ALL_TO_PERSON": "Atribuir todas as tarefas neste dia para {{name}}", "ASSIGN_DATE": "Atribuir data de vencimento", "ASSIGN_TASK": "Atribuir tarefa", @@ -42,6 +67,7 @@ "CUSTOM_TASK_TYPE": "Tipo de tarefa personalizada", "DO_YOU_NEED_TO_OVERRIDE": "Você precisa substituir o salário dos trabalhadores para esta tarefa?", "DO_YOU_WANT_TO_ASSIGN": "Você quer atribuir a tarefa agora?", + "DUPLICATE_NAME": "Um produto com este nome já existe. Por favor escolha outro.", "EDIT_CUSTOM_TASK": "Editar tarefa personalizada", "FIELD_WORK_VIEW": { "OTHER_TYPE_OF_FIELD_WORK": "Descreva o tipo de trabalho de campo", @@ -138,14 +164,34 @@ "SELECT_ALL_PLANS": "Selecionar todos os planos", "SELECT_TASK_TYPE": "Selecionar tipo de tarefa", "SOIL_AMENDMENT_VIEW": { - "IS_PERMITTED": "O corretivo de solo está na lista de substâncias permitidas?", + "ADVANCED": "Avançado", + "APPLICATION_METHOD": "Método de aplicação", + "APPLICATION_RATE": "Taxa de aplicação", + "APPLIED_TO": "Aplicado a <1>{{percentOfArea}}% da sua <4>{{locationArea}} {{locationAreaUnit}} {{locationType}}", + "APPLIED_TO_MULTIPLE": "Aplicado a <1>{{ percentOfArea }}% de seus locais {{locationCount}} com área total <5>{{ locationArea }} {{ locationAreaUnit }}", + "BANDED": "Em faixas", + "BROADCAST": "A lanço", + "FERTIGATION": "Fertirrigação", + "FOLIAR": "Foliar", + "FURROW_HOLE": "Sulco / cova", + "FURROW_HOLE_DEPTH": "Profundidade do sulco/furo", + "FURROW_HOLE_DEPTH_PLACEHOLDER": "A que profundidade o adubo ou corretivo foi aplicado?", + "IS_PERMITTED": "O adubo ou corretivo de solo está na lista de substâncias permitidas?", "MOISTURE_RETENTION": "Retenção de umidade", "NUTRIENT_AVAILABILITY": "Disponibilidade de nutriente", "OTHER": "Outro", + "OTHER_METHOD": "Conte-nos mais sobre o método de aplicação", + "OTHER_METHOD_PLACEHOLDER": "Descreva seu método de adubação ou correção do solo...", "OTHER_PURPOSE": "Descreva o objetivo", + "PERECENT_TO_AMEND": "% da área a ser alterada", "PH": "pH", "PURPOSE": "Objetivo", - "STRUCTURE": "Estrutura" + "QUANTITY": "Quantidade a aplicar", + "SIDE_DRESS": "Lateral (superfície)", + "STRUCTURE": "Estrutura", + "TOTAL_AREA": "Área total da aplicação", + "VOLUME": "Volume", + "WEIGHT": "Peso" }, "TASK": "tarefa", "TASK_NOTES_CHAR_LIMIT": "As observações não podem exceder 10.000 caractéres", @@ -489,6 +535,13 @@ "LOCATION": "localização", "TASK": "tarefa" }, + "ERROR_FALLBACK": { + "CONTACT": "Ainda atolado(a)? Não se preocupe, estamos aqui para tirar você daí: <1>{{supportEmail}}", + "MAIN": "Às vezes, o LiteFarm se perde e só precisa de uma ajudinha. Uma dessas soluções geralmente resolve o problema:", + "RELOAD": "Recarregue a página", + "SUBTITLE": "Não se preocupe, não é você, somos nós.", + "TITLE": "Ops! Parece que esta página se perdeu!" + }, "EXPENSE": { "ADD_EXPENSE": { "ADD_CUSTOM_EXPENSE": "Adicionar despesa personalizada", @@ -684,8 +737,11 @@ "ADD_TITLE": "Adicionar ao seu mapa", "AREAS": "Áreas", "BARN": "Celeiro", + "BUFFER_ZONE": "Zona de amortecimento/proteção", "BZ": "Zona de amortecimento/proteção", "CA": "Área cerimonial", + "CEREMONIAL_AREA": "Área cerimonial", + "FARM_SITE_BOUNDARY": "Limites do sítio/fazenda", "FENCE": "Cerca", "FIELD": "Campo/parcela", "FSB": "Limites do sítio/fazenda", @@ -696,6 +752,7 @@ "LABEL": "Rótulos", "LINES": "Linhas", "NA": "Área natural", + "NATURAL_AREA": "Área natural", "POINTS": "Pontos", "RESIDENCE": "Residência", "SATELLITE": "Imagem de satélite", @@ -703,6 +760,7 @@ "SHOW_ALL": "Mostrar todos", "SURFACE_WATER": "Água superficial", "TITLE": "Filtre o seu mapa", + "WATER_VALVE": "Ponto de captação de água", "WATERCOURSE": "Curso d'água", "WV": "Ponto de captação de água" }, @@ -837,6 +895,7 @@ }, "REPORT": { "DATES": "Datas", + "FILE_TITLE": "Relatório Financeiro", "SETTINGS": "Configurações de exportação", "TRANSACTION": "Transação", "TRANSACTIONS": "Transações" @@ -898,7 +957,6 @@ "MAMMALS": "Mamíferos", "PLANTS": "Plantas", "SPECIES_COUNT_one": "{{count}} espécie", - "SPECIES_COUNT_many": "{{count}} espécies", "SPECIES_COUNT_other": "{{count}} espécies", "TITLE": "Biodiversidade" }, @@ -913,7 +971,6 @@ "PRICES": { "INFO": "Mostramos a trajetória comparativa entre seus preços de venda em relação aos preços de venda dos mesmos produtos encontrados a uma determinada distância de você, utilizando dados coletados na rede LiteFarm.", "NEARBY_FARMS_one": "O preço de mercado é baseado em {{count}} fazenda na sua região", - "NEARBY_FARMS_many": "O preço de mercado é baseado em {{count}} fazendas na sua região", "NEARBY_FARMS_other": "O preço de mercado é baseado em {{count}} fazendas na sua região", "NETWORK_PRICE": "Preço de mercado", "NO_ADDRESS": "Atualmente você não tem um endereço na LiteFarm. Atualize-o em seu perfil para obter informações de preços nas proximidades!", @@ -1129,7 +1186,6 @@ "NUMBER_OF_CONTAINER": "Nº des recipientes", "PENDING_TASK": "Tarefas pendentes", "PLAN_AND_ID": "Plan {{id}}", - "PLAN_AND_ID_plural": "", "PLAN_NAME": "Nome do plano de cultivo", "PLAN_NOTES": "Notas do plano", "PLANT_SPACING": "Espaçamento de plantas", @@ -1402,9 +1458,9 @@ "CLEAR_ALL": "Limpar tudo" }, "RELEASE": { - "BETTER": "MISSING", + "BETTER": "O LiteFarm ficou ainda melhor!", "LITEFARM_UPDATED": "LiteFarm v{{version}} já está disponível!", - "NOTES": "MISSING" + "NOTES": "Nota de lançamento" }, "REPEAT_PLAN": { "AFTER": "Após", @@ -1804,6 +1860,7 @@ "SELECT_DATE": "Selecionar a data da tarefa", "SELECT_TASK_LOCATIONS": "Selecionar o(s) local(is) da tarefa", "SELECT_WILD_CROP": "Esta tarefa é para um cultivo silvestre", + "SOIL_AMENDMENT_LOCATION": "Selecionar localização(ões) de adubação ou correção do solo", "STATUS": { "ABANDONED": "Abandonada", "COMPLETED": "Completa", @@ -1813,7 +1870,6 @@ }, "TASK": "tarefa", "TASKS_COUNT_one": "{{count}} tarefa", - "TASKS_COUNT_many": "{{count}} tarefas", "TASKS_COUNT_other": "{{count}} tarefas", "TRANSPLANT": "Transplantar", "TRANSPLANT_LOCATIONS": "Selecione um local de transplante", diff --git a/packages/webapp/src/apiConfig.js b/packages/webapp/src/apiConfig.js index 69a0559236..f22ec224e2 100644 --- a/packages/webapp/src/apiConfig.js +++ b/packages/webapp/src/apiConfig.js @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022 LiteFarm.org + * Copyright 2019-2024 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -74,6 +74,11 @@ export const alertsUrl = `${URI}/notification_user/subscribe`; export const notificationsUrl = `${URI}/notification_user`; export const clearAlertsUrl = `${URI}/notification_user/clear_alerts`; export const sensorUrl = `${URI}/sensor`; +export const soilAmendmentMethodsUrl = `${URI}/soil_amendment_methods`; +export const soilAmendmentPurposesUrl = `${URI}/soil_amendment_purposes`; +export const soilAmendmentFertiliserTypesUrl = `${URI}/soil_amendment_fertiliser_types`; +export const productUrl = `${URI}/product`; + export const url = URI; export default { @@ -117,5 +122,9 @@ export default { alertsUrl, notificationsUrl, sensorUrl, + soilAmendmentMethodsUrl, + soilAmendmentPurposesUrl, + soilAmendmentFertiliserTypesUrl, + productUrl, url, }; diff --git a/packages/webapp/src/assets/colors.scss b/packages/webapp/src/assets/colors.scss index 342f26b2e3..5c3b10c702 100644 --- a/packages/webapp/src/assets/colors.scss +++ b/packages/webapp/src/assets/colors.scss @@ -4,16 +4,16 @@ --teal600: #3ea992; --teal500: #89d1c7; --teal100: #f1fbf9; - --teal50: #EBF5F4; + --teal50: #ebf5f4; --green800: #048211; - --green500: #78C99E; - --green700: #558F70; + --green500: #78c99e; + --green700: #558f70; --green400: #a8e6bd; --green200: #c7efd3; --green100: #e3f8ec; --secondaryGreen800: #495c51; - --secondaryGreen200: #C1E6D2; - --secondaryGreen50: #F2FAF5; + --secondaryGreen200: #c1e6d2; + --secondaryGreen50: #f2faf5; --yellow700: #ffb800; --yellow400: #fed450; --yellow300: #fce38d; @@ -27,12 +27,12 @@ --overlay: rgba(36, 39, 48, 0.5); --red700: #d02620; --red400: #f58282; - --red200: #FFE8E8; + --red200: #ffe8e8; --orange700: #ffa73f; --orange400: #ffc888; --purple700: #8f26f0; --purple400: #ffe55b; - --brightGreen700: #037A0F; + --brightGreen700: #037a0f; --brightGreen400: #a6f7ae; --cyan700: #03a6ca; --cayn400: #4fdbfa; @@ -40,8 +40,8 @@ --blue700: #0669e1; --grey1: #333333; --brown200: #fff6ed; - --brown700: #AA5F04; - --brown900: #7E4C0E; + --brown700: #aa5f04; + --brown900: #7e4c0e; --info: var(--blue700); --success: var(--brightGreen700); --warning: var(--orange700); @@ -62,7 +62,72 @@ --border: var(--grey200); --checkbox: var(--teal700); --tooltipFont: var(--grey1); - --modalPrimary: white; + --modalPrimary: white; --bgInputListTile: white; - --mainBackground: #FAFCFB; + --mainBackground: #fafcfb; + --tableAlternateRowBackground: #f7fbff; + --tableSortPill: #f4f5f7; + --tableV2Header: white; + + // New design system colours + --White: #fff; + --Colors-Backgrounds-Background-color: #fafcfb; + + --Colors-Primary-Primary-teal-50: #ebf5f4; + --Colors-Primary-Primary-teal-100: #c0e1dd; + --Colors-Primary-Primary-teal-300: #78bdb6; + --Colors-Primary-Primary-teal-400: #5db1a8; + --Colors-Primary-Primary-teal-500: #359d92; + --Colors-Primary-Primary-teal-600: #308f85; + --Colors-Primary-Primary-teal-700: #266f68; + --Colors-Primary-Primary-teal-700-2: #028577; + --Colors-Primary-Primary-teal-900: #16423d; + + --Colors-Secondary-Secondary-green-50: #f2faf5; + --Colors-Secondary-Secondary-green-100: #d5eee1; + --Colors-Secondary-Secondary-green-200: #c1e6d2; + --Colors-Secondary-Secondary-green-400: #93d4b1; + --Colors-Secondary-Secondary-green-500: #f2faf5; + --Colors-Secondary-Secondary-green-600: #6db790; + --Colors-Secondary-Secondary-green-700: #558f70; + --Colors-Secondary-Secondary-green-800: #426f57; + --Colors-Secondary-Secondary-green-900: #325442; + + --Colors-Neutral-Neutral-50: #f0f1f3; + --Colors-Neutral-Neutral-100: #d0d4db; + --Colors-Neutral-Neutral-200: #b9bfc9; + --Colors-Neutral-Neutral-300: #98a1b1; + --Colors-Neutral-Neutral-400: #858fa1; + --Colors-Neutral-Neutral-500: #66738a; + --Colors-Neutral-Neutral-600: #5d697e; + --Colors-Neutral-Neutral-700: #485262; + --Colors-Neutral-Neutral-900: #2b303a; + + --Btn-primary-pristine: #ffcf54; + --Btn-primary-hover: #e8a700; + --Btn-primary-disabled: #e7ebf2; + + --Colors-Accent-Accent-yellow-50: #fff8e6; + --Colors-Accent-Accent-yellow-100: #ffe9b0; + --Colors-Accent-Accent-yellow-400: #ffc633; + --Colors-Accent-Accent-yellow-500: #ffb800; + --Colors-Accent-Accent-yellow-600: #e8a700; + --Colors-Accent-Accent-yellow-700: #b58300; + --Colors-Accent-Accent-yellow-900: #6b4d00; + --Colors-Accent---singles-Red-light: #ffdad9; + --Colors-Accent---singles-Red-full: #d02620; + --Colors-Accent---singles-Red-dark: #520f0d; + --Colors-Accent---singles-Blue-light: #e9f3ff; + --Colors-Accent---singles-Blue-full: #0669e1; + --Colors-Accent---singles-Blue-dark: #032d61; + --Colors-Accent---singles-Brown-full: #aa5f04; + --Colors-Accent---singles-Brown-dark: #633700; + --Colors-Accent---singles-Purple-light: #f4e8ff; + --Colors-Accent---singles-Purple-full: #8f26f0; + + --Form-focus: #89d1c7; + + // Named in Figma but not in design system + --Colors-Primary-green: #247360; + --Colors-Light-green: #78ca9e; } diff --git a/packages/webapp/src/assets/images/edit-02.svg b/packages/webapp/src/assets/images/edit-02.svg new file mode 100644 index 0000000000..fd13fd1ccd --- /dev/null +++ b/packages/webapp/src/assets/images/edit-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/webapp/src/assets/images/errorFallback/background.svg b/packages/webapp/src/assets/images/errorFallback/background.svg new file mode 100644 index 0000000000..63dc397f50 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/background.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webapp/src/assets/images/errorFallback/background_mobile.svg b/packages/webapp/src/assets/images/errorFallback/background_mobile.svg new file mode 100644 index 0000000000..5c1125d1c3 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/background_mobile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/webapp/src/assets/images/errorFallback/farmer_desktop.svg b/packages/webapp/src/assets/images/errorFallback/farmer_desktop.svg new file mode 100644 index 0000000000..161c6e4649 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/farmer_desktop.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/webapp/src/assets/images/errorFallback/farmer_mobile.svg b/packages/webapp/src/assets/images/errorFallback/farmer_mobile.svg new file mode 100644 index 0000000000..d17bc57524 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/farmer_mobile.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/webapp/src/assets/images/errorFallback/logout.svg b/packages/webapp/src/assets/images/errorFallback/logout.svg new file mode 100644 index 0000000000..767987e896 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/webapp/src/assets/images/errorFallback/refresh.svg b/packages/webapp/src/assets/images/errorFallback/refresh.svg new file mode 100644 index 0000000000..d881f680d7 --- /dev/null +++ b/packages/webapp/src/assets/images/errorFallback/refresh.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/webapp/src/assets/images/number-input-decrement.svg b/packages/webapp/src/assets/images/number-input-decrement.svg new file mode 100644 index 0000000000..80b864dd69 --- /dev/null +++ b/packages/webapp/src/assets/images/number-input-decrement.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/webapp/src/assets/images/number-input-increment.svg b/packages/webapp/src/assets/images/number-input-increment.svg new file mode 100644 index 0000000000..809a3136d3 --- /dev/null +++ b/packages/webapp/src/assets/images/number-input-increment.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/webapp/src/assets/images/plus-circle.svg b/packages/webapp/src/assets/images/plus-circle.svg new file mode 100644 index 0000000000..ce49b5d05f --- /dev/null +++ b/packages/webapp/src/assets/images/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/webapp/src/assets/images/ratio-option.svg b/packages/webapp/src/assets/images/ratio-option.svg new file mode 100644 index 0000000000..8e889179dc --- /dev/null +++ b/packages/webapp/src/assets/images/ratio-option.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/webapp/src/assets/images/swap.svg b/packages/webapp/src/assets/images/swap.svg new file mode 100644 index 0000000000..99ef4c83f3 --- /dev/null +++ b/packages/webapp/src/assets/images/swap.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/webapp/src/assets/images/x-icon.svg b/packages/webapp/src/assets/images/x-icon.svg new file mode 100644 index 0000000000..59e6331e82 --- /dev/null +++ b/packages/webapp/src/assets/images/x-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/webapp/src/assets/mixin.scss b/packages/webapp/src/assets/mixin.scss index d53fdd0e92..a98a73b0d1 100644 --- a/packages/webapp/src/assets/mixin.scss +++ b/packages/webapp/src/assets/mixin.scss @@ -33,3 +33,63 @@ @content; } } + +@mixin md-breakpoint { + @media only screen and (max-width: 900px) { + @content; + } +} + +@mixin lg-breakpoint { + @media only screen and (min-width: 1200px) { + @content; + } +} + +@mixin xlg-breakpoint { + @media only screen and (min-width: 1536px) { + @content; + } +} + +// Custom icons from figma use a variety of strokes and fills +@mixin svgColorFill($newColor) { + svg { + path { + &[stroke] { + stroke: $newColor; + } + &[fill] { + fill: $newColor; + } + } + rect { + &[stroke] { + stroke: $newColor; + } + &[fill] { + fill: $newColor; + } + } + circle { + &[stroke] { + stroke: $newColor; + } + &[fill] { + fill: $newColor; + } + } + } +} + +@mixin truncateText() { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@mixin flex-column-gap($gap: 16px) { + display: flex; + flex-direction: column; + gap: $gap; +} diff --git a/packages/webapp/src/components/Crop/Detail.jsx b/packages/webapp/src/components/Crop/Detail.jsx index 9bd29def77..d1b460e8df 100644 --- a/packages/webapp/src/components/Crop/Detail.jsx +++ b/packages/webapp/src/components/Crop/Detail.jsx @@ -75,11 +75,6 @@ function PureCropDetail({ }, ]} /> - - {/* */} )} {isEditing && ( diff --git a/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/index.tsx b/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/index.tsx new file mode 100644 index 0000000000..bf788e2e4b --- /dev/null +++ b/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/index.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2024 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 { useTranslation, Trans } from 'react-i18next'; +import { useTheme } from '@mui/styles'; +import { useMediaQuery } from '@mui/material'; +import styles from './styles.module.scss'; +import { Title, Main } from '../../Typography'; +import TextButton from '../../Form/Button/TextButton'; +import { ReactComponent as Background } from '../../../assets/images/errorFallback/background.svg'; +import { ReactComponent as MobileBackground } from '../../../assets/images/errorFallback/background_mobile.svg'; +import { ReactComponent as FarmerDesktop } from '../../../assets/images/errorFallback/farmer_desktop.svg'; +import { ReactComponent as FarmerMobile } from '../../../assets/images/errorFallback/farmer_mobile.svg'; +import { ReactComponent as RefreshIcon } from '../../../assets/images/errorFallback/refresh.svg'; +import { ReactComponent as LogoutIcon } from '../../../assets/images/errorFallback/logout.svg'; +import { ReactComponent as Logo } from '../../../assets/images/nav/logo-large.svg'; +import { SUPPORT_EMAIL } from '../../../util/constants'; + +interface PureReactErrorFallbackProps { + handleReload: () => Promise; + handleLogout: () => void; +} + +export const PureReactErrorFallback = ({ + handleReload, + handleLogout, +}: PureReactErrorFallbackProps) => { + const { t } = useTranslation(); + + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up('sm')); + + return ( +
+ {isDesktop ? ( + + ) : ( + + )} + +
+ {t('ERROR_FALLBACK.TITLE')} +
{t('ERROR_FALLBACK.SUBTITLE')}
+
{t('ERROR_FALLBACK.MAIN')}
+
+ + + {t('ERROR_FALLBACK.RELOAD')} + +
{t('common:OR')}
+ + + {t('PROFILE_FLOATER.LOG_OUT')} + +
+
+ , + }} + /> +
+
+ {isDesktop ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/styles.module.scss b/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/styles.module.scss new file mode 100644 index 0000000000..bb58201a3c --- /dev/null +++ b/packages/webapp/src/components/ErrorHandler/PureReactErrorFallback/styles.module.scss @@ -0,0 +1,149 @@ +/* + * Copyright 2024 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 '../../../assets/mixin'; + +.container { + min-height: 100vh; + min-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.background { + position: fixed; + background-color: var(--mainBackground, #fafcfb); + top: 56px; + z-index: -1; + + @include xs-breakpoint { + top: 0px; + width: 100vw; + padding-block: 48px; + } +} + +.logo { + height: 100px; + width: auto; + margin-block: 48px; + margin-left: 10%; + align-self: flex-start; + + @include xs-breakpoint { + height: 56px; + margin: 32px; + } +} + +.textContainer { + width: 90%; + max-width: 648px; + background-color: var(--mainBackground, #fafcfb); + padding: 32px; + + p { + font-family: 'Open Sans'; + line-height: normal; + font-style: normal; + font-size: 18px; + color: var(--grey900, #282b36); + letter-spacing: -0.396px; + } + + @include xs-breakpoint { + width: 100%; + padding-bottom: 56px; + + p { + font-size: 14px; + letter-spacing: -0.308px; + } + } +} + +.title { + color: var(--Colors-Primary-Primary-teal-900, #16423d); + text-align: center; + font-size: 28px; + font-weight: 600; + line-height: 48px; + letter-spacing: -0.616px; + + @include xs-breakpoint { + text-align: start; + font-size: 20px; + line-height: 32px; + letter-spacing: -0.44px; + } +} + +.subtitle, +.supportText { + font-weight: 600; +} + +.buttonContainer { + display: flex; + align-items: center; + gap: 24px; + padding-block: 32px; +} + +.iconLink { + display: flex; + align-items: center; + gap: 4px; + + font-size: 18px; + font-weight: 700; + color: var(--Colors-Primary-Primary-teal-900, #16423d); + + @include xs-breakpoint { + font-size: 16px; + } +} + +.icon { + flex: none; + height: 32px; + width: 32px; + + @include xs-breakpoint { + height: 24px; + } +} + +.or { + font-weight: 600; +} + +.email { + color: var(--Colors-Accent---singles-Brown-dark, #633700); + font-weight: 700; + text-decoration: none; +} + +.farmerDesktop { + height: 415px; +} + +.farmerMobile { + height: 304px; + align-self: end; + transform: translateY(-56px); +} diff --git a/packages/webapp/src/components/Expandable/useExpandableItem.js b/packages/webapp/src/components/Expandable/useExpandableItem.js index bb49cb153d..4c41f66154 100644 --- a/packages/webapp/src/components/Expandable/useExpandableItem.js +++ b/packages/webapp/src/components/Expandable/useExpandableItem.js @@ -15,7 +15,7 @@ import { useState } from 'react'; import PropTypes from 'prop-types'; -export default function useExpandable({ defaultExpandedIds = [], isSingleExpandable }) { +export default function useExpandable({ defaultExpandedIds = [], isSingleExpandable } = {}) { const [expandedIds, setExpandedIds] = useState(defaultExpandedIds); const expand = (id) => { diff --git a/packages/webapp/src/components/Finances/AddTransactionButton/index.jsx b/packages/webapp/src/components/Finances/AddTransactionButton/index.jsx index 135adaec86..a7c7719312 100644 --- a/packages/webapp/src/components/Finances/AddTransactionButton/index.jsx +++ b/packages/webapp/src/components/Finances/AddTransactionButton/index.jsx @@ -16,9 +16,10 @@ import { forwardRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; +import { useTheme } from '@mui/styles'; +import { useMediaQuery } from '@mui/material'; import { setPersistedPaths } from '../../../containers/hooks/useHookFormPersist/hookFormPersistSlice'; import history from '../../../history'; -import useIsAboveBreakpoint from '../../../hooks/useIsAboveBreakpoint'; import DropdownButton from '../../Form/DropDownButton'; import FloatingButtonMenu from '../../Menu/FloatingButtonMenu'; import FloatingMenu from '../../Menu/FloatingButtonMenu/FloatingMenu'; @@ -58,7 +59,8 @@ Menu.displayName = 'Menu'; export default function AddTransactionButton() { const { t } = useTranslation(); - const isAboveBreakPoint = useIsAboveBreakpoint(`(min-width: 856px)`); + const theme = useTheme(); + const isAboveBreakPoint = useMediaQuery(theme.breakpoints.up('md')); return ( <> diff --git a/packages/webapp/src/components/Form/Button/SmallButton.tsx b/packages/webapp/src/components/Form/Button/SmallButton.tsx new file mode 100644 index 0000000000..f152808f9a --- /dev/null +++ b/packages/webapp/src/components/Form/Button/SmallButton.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2024 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 { ReactNode } from 'react'; +import clsx from 'clsx'; +import { useTranslation, TFunction } from 'react-i18next'; +import { ReactComponent as XIcon } from '../../../assets/images/x-icon.svg'; +import styles from './button.module.scss'; + +type Variant = 'remove'; + +type ButtonProps = { + variant?: Variant; + children?: ReactNode; + disabled?: boolean; + className?: string; + onClick?: (e: React.MouseEvent) => void; + type?: 'button' | 'submit' | 'reset'; + inputRef?: any; +}; + +interface Config { + [key: string]: { + translationKey: string; + icon?: ReactNode; + format?: (text: string) => string; + }; +} + +const CONFIG: Config = { + remove: { + translationKey: 'common:REMOVE', // t('common:REMOVE') + icon: , + format: (text) => text.toLocaleLowerCase(), + }, +}; + +const generateContent = (t: TFunction, variant: Variant): ReactNode => { + const { translationKey, icon, format = (text: string): string => text } = CONFIG[variant]; + + return ( + <> + {icon} + {format(t(translationKey))} + + ); +}; + +const SmallButton = ({ + variant = 'remove', + children, + disabled = false, + className, + onClick, + type = 'button', + inputRef, + ...props +}: ButtonProps) => { + const { t } = useTranslation('common'); + const content = children || generateContent(t, variant); + + return ( + + ); +}; + +export default SmallButton; diff --git a/packages/webapp/src/components/Form/Button/button.module.scss b/packages/webapp/src/components/Form/Button/button.module.scss index 0d09f6a2ad..7cdbc790b6 100644 --- a/packages/webapp/src/components/Form/Button/button.module.scss +++ b/packages/webapp/src/components/Form/Button/button.module.scss @@ -1,92 +1,211 @@ +/* + * Copyright 2021-2024 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 '../../../assets/mixin'; -.primary { - background-color: var(--btnPrimary); - color: var(--fontColor); - text-decoration: none; - box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); - border: none; + +.btn.primary { + --background: var(--Btn-primary-pristine); + --border: none; + --box-shadow: 0px 1px 2px 0px var(--Colors-Neutral-Neutral-500); + --color: var(--Colors-Accent-Accent-yellow-900); + + --hover-background: var(--Btn-primary-hover); + --hover-box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.25); + --hover-color: var(--Colors-Accent-Accent-yellow-50); + + --active-background: var(--Colors-Accent-Accent-yellow-500); + --active-color: var(--Colors-Accent-Accent-yellow-900); + + --focus-border: 2px solid var(--Colors-Accent-Accent-yellow-600); } -.secondary { - background-color: var(--btnSecondary); - color: var(--labels); - border-color: var(--iconDefault); - border: 1px solid; - box-sizing: border-box; - text-decoration: none; +.btn.secondary { + --background: var(--White); + --border: 1px solid var(--Colors-Neutral-Neutral-200); + --color: var(--Colors-Neutral-Neutral-500); + + --hover-border: 1px solid var(--Colors-Neutral-Neutral-500); + --hover-box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.25); + --hover-color: var(--Colors-Neutral-Neutral-500); + + --active-border: 1px solid var(--Colors-Neutral-Neutral-900); + --active-color: var(--Colors-Neutral-Neutral-900); } -.success { - background-color: var(--btnSecondary); - color: var(--teal700); - border-color: var(--teal700); - border: 1px solid; - box-sizing: border-box; - text-decoration: none; - box-shadow: 0px 2px 4px rgba(102, 115, 138, 0.3); +.btn.secondary-2 { + --background: var(--White); + --border: 1px solid var(--Colors-Primary-Primary-teal-600); + --color: var(--Colors-Primary-Primary-teal-600); + + --hover-background: var(--White); + --hover-border: 1px solid var(--Colors-Primary-Primary-teal-900); + --hover-box-shadow: 0px 1px 2px 0px var(--Colors-Neutral-Neutral-500); + --hover-color: var(--Colors-Primary-Primary-teal-900); + + --active-background: var(--Colors-Secondary-Secondary-green-100); + --active-border: 1px solid var(--Colors-Primary-Primary-teal-600); + --active-color: var(--Colors-Primary-Primary-teal-600); } -.error { - background-color: var(--error); - color: white; - text-decoration: none; - box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); - border: none; +.btn.secondary-cta { + --background: none; + --border: none; + --color: var(--Colors-Accent-Accent-yellow-900); + + --hover-background: var(--Colors-Accent-Accent-yellow-50); + --hover-box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.25); + --hover-color: var(--Colors-Accent-Accent-yellow-700); + + --active-background: var(--Colors-Accent-Accent-yellow-700); + --active-color: var(--Colors-Accent-Accent-yellow-100); + + --focus-border: 2px solid var(--Colors-Accent-Accent-yellow-600); + --focus-box-shadow: none; } -.warning { - background-color: var(--brown700); - color: white; - text-decoration: none; - box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); - border: none; +.btn.error { + --background: var(--error); + --color: var(--White); + --box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); + --border: none; + + /* These states not yet defined for this variant */ + --hover-background: var(--background); + --hover-border: var(--border); + --hover-color: var(--color); + + --active-background: var(--background); + --active-border: var(--border); + --active-color: var(--color); } +.btn.warning { + --background: var(--brown700); + --color: var(--White); + --box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); + --border: none; + + /* These states not yet defined for this variant */ + --hover-background: var(--background); + --hover-border: var(--border); + --hover-color: var(--color); + + --active-background: var(--background); + --active-border: var(--border); + --active-color: var(--color); +} .btn { - border-radius: 4px; - font-weight: 600; - size: 16px; - line-height: 24px; + border-radius: 8px; min-height: 48px; padding: 0 16px; + font-size: 16px; - cursor: pointer; + font-family: 'Open Sans', 'SansSerif', serif; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.4px; + text-decoration: none; + display: flex; align-items: center; justify-content: center; - font-family: 'Open Sans', 'SansSerif', serif; + gap: 8px; + + cursor: pointer; + + /* Used in all but secondary-cta variant */ + --focus-box-shadow: 6px -6px 0px 0px var(--Colors-Secondary-Secondary-green-200), + -6px 6px 0px 0px var(--Colors-Secondary-Secondary-green-200), + -6px -6px 0px 0px var(--Colors-Secondary-Secondary-green-200), + 6px 6px 0px 0px var(--Colors-Secondary-Secondary-green-200); + + /* Based on variant */ + background: var(--background); + border: var(--border); + box-shadow: var(--box-shadow); + color: var(--color); +} + +.btn svg path { + stroke: var(--color); +} + +.btn:hover:enabled { + background: var(--hover-background); + border: var(--hover-border); + box-shadow: var(--hover-box-shadow); + color: var(--hover-color); +} + +.btn:hover:enabled svg path { + stroke: var(--hover-color); +} + +.btn:active:enabled { + background: var(--active-background); + border: var(--active-border); + box-shadow: none; + color: var(--active-color); +} + +.btn:active:enabled svg path { + stroke: var(--active-color); } .btn:disabled { border: none; - color: var(--inputDisabled); - background-color: var(--btnDisabled); + color: var(--Colors-Neutral-Neutral-300); + background: var(--Btn-primary-disabled); box-shadow: none; cursor: default; } -.primary:hover:enabled { - background-color: var(--btnHoverPrimary); - box-shadow: 0px 4px 12px rgba(102, 115, 138, 0.4); +.btn:disabled svg path { + stroke: var(--Colors-Neutral-Neutral-300); } -.secondary:hover:enabled { - border-color: var(--labels); +.btn.error { + --background: var(--error); + --color: white; + --box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); + --border: none; } -.fullLength { - width: 100%; +.btn.warning { + --background: var(--brown700); + --color: white; + --box-shadow: 0 2px 8px rgba(102, 115, 138, 0.3); + --border: none; } .btn:focus { outline: none; + border: var(--focus-border); + box-shadow: var(--focus-box-shadow); +} + +.fullLength { + width: 100%; } .sm { font-size: 14px; min-height: 32px; padding: 0 16px; + gap: 4px; } .textButton { @@ -99,3 +218,22 @@ color: var(--grey500); } } + +.smallButton { + display: flex; + align-items: center; + gap: 4px; + border-radius: 16px; + padding: 4px 8px; + border: 1px solid var(--Colors-Primary-Primary-teal-50); + background-color: var(--Colors-Primary-Primary-teal-50); + color: var(--Colors-Primary-Primary-teal-700); + font-weight: 600; + width: fit-content; + cursor: pointer; + + &:hover { + color: var(--Colors-Primary-Primary-teal-50); + background-color: var(--Colors-Primary-Primary-teal-600); + } +} diff --git a/packages/webapp/src/components/Form/Button/index.tsx b/packages/webapp/src/components/Form/Button/index.tsx index eb3ee2b3b4..d70cf3b38b 100644 --- a/packages/webapp/src/components/Form/Button/index.tsx +++ b/packages/webapp/src/components/Form/Button/index.tsx @@ -1,9 +1,24 @@ +/* + * Copyright 2022-2024 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 React, { ReactNode } from 'react'; import styles from './button.module.scss'; import clsx from 'clsx'; type ButtonProps = { - color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'none'; + color?: 'primary' | 'secondary' | 'secondary-2' | 'secondary-cta' | 'warning' | 'error' | 'none'; children?: ReactNode; sm?: boolean; disabled?: boolean; diff --git a/packages/webapp/src/components/Form/CompositionInputs/NumberInputWithSelect.tsx b/packages/webapp/src/components/Form/CompositionInputs/NumberInputWithSelect.tsx new file mode 100644 index 0000000000..8005853a3e --- /dev/null +++ b/packages/webapp/src/components/Form/CompositionInputs/NumberInputWithSelect.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2024 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 { ReactNode, useEffect } from 'react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import ReactSelect from '../ReactSelect'; +import useReactSelectStyles from '../Unit/useReactSelectStyles'; +import useNumberInput from '../NumberInput/useNumberInput'; +import InputBase from '../InputBase'; +import { styles as reactSelectDefaultStyles } from '../ReactSelect'; +import { ReactComponent as RatioOptionIcon } from '../../../assets/images/ratio-option.svg'; +import styles from './styles.module.scss'; + +export type Option = { value: string; label: string }; + +export type NumberInputWithSelectProps = { + name: string; + label: string; + unitOptions?: Option[]; + disabled?: boolean; + error?: string; + className?: string; + value?: number | null; + unit: string; + unitFieldName?: string; + onChange: (fieldName: string, value: number | string | null) => void; + onBlur?: () => void; + reactSelectWidth?: number; +}; + +const REACT_SELECT_WIDTH = 44; + +const formatOptionLabel = ({ label, value }: Option): ReactNode => { + return value === 'ratio' ? : label; +}; + +const NumberInputWithSelect = ({ + name, + label, + unitOptions = [], + disabled, + error, + className, + onChange, + onBlur, + value, + unit, + unitFieldName = '', + reactSelectWidth = REACT_SELECT_WIDTH, +}: NumberInputWithSelectProps) => { + const { t } = useTranslation(); + + const reactSelectStyles = useReactSelectStyles(disabled, { reactSelectWidth }); + reactSelectStyles.control = (provided) => ({ + ...provided, + boxShadow: 'none', + borderRadius: 0, + height: '46px', + paddingLeft: disabled ? '4px' : '8px', + fontSize: '16px', + border: 'none', + background: 'inherit', + }); + reactSelectStyles.singleValue = (provided) => ({ + ...provided, + color: 'var(--Colors-Neutral-Neutral-600, #5D697E);', + }); + reactSelectStyles.option = (provided, state) => ({ + ...reactSelectDefaultStyles.option?.(provided, state), + color: 'var(--Colors-Neutral-Neutral-600, #5D697E);', + }); + reactSelectStyles.valueContainer = (provided) => ({ + ...provided, + padding: '0', + width: `${reactSelectWidth - 19}px`, + display: 'flex', + background: disabled ? 'var(--inputDisabled)' : 'inherit', + justifyContent: disabled ? 'flex-end' : 'center', + }); + + const { inputProps, update, clear, numericValue } = useNumberInput({ + onChange: (value) => onChange(name, value), + initialValue: value, + max: 999999999, + }); + + useEffect(() => { + // If the value is updated from the parent, update the visible value in the input. + const isNumericValueNumber = !isNaN(numericValue); + const isValueNumber = typeof value === 'number'; + + if ((isNumericValueNumber || isValueNumber) && numericValue !== value) { + update(value ?? NaN); + } + }, [numericValue, value]); + + return ( +
+ { + onBlur?.(); + inputProps.onBlur?.(e); + }} + onResetIconClick={clear} + resetIconPosition="left" + rightSection={ + unitOptions.length ? ( +
e.preventDefault()}> + onChange(unitFieldName, option?.value || null)} + value={unitOptions.find(({ value }) => value === unit)} + styles={{ ...(reactSelectStyles as any) }} + isDisabled={disabled} + onBlur={onBlur} + formatOptionLabel={formatOptionLabel} + /> +
+ ) : ( + {unit} + ) + } + /> +
+ ); +}; + +export default NumberInputWithSelect; diff --git a/packages/webapp/src/components/Form/CompositionInputs/index.tsx b/packages/webapp/src/components/Form/CompositionInputs/index.tsx new file mode 100644 index 0000000000..e8888e0270 --- /dev/null +++ b/packages/webapp/src/components/Form/CompositionInputs/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2024 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 { BsFillExclamationCircleFill } from 'react-icons/bs'; +import InputBaseLabel from '../InputBase/InputBaseLabel'; +import NumberInputWithSelect, { NumberInputWithSelectProps } from './NumberInputWithSelect'; +import styles from './styles.module.scss'; + +type CompositionInputsProps = Omit< + NumberInputWithSelectProps, + 'name' | 'label' | 'value' | 'unit' +> & { + mainLabel?: string; + inputsInfo: { name: string; label: string }[]; + values: { [key: string]: any }; + unit?: string; + shouldShowErrorMessage?: boolean; +}; + +/** + * Component for inputs that share the same unit. + * Changing the unit of one input updates the units of all inputs. + * Units that require unit system conversions are not supported. + */ +const CompositionInputs = ({ + mainLabel = '', + inputsInfo, + error = '', + shouldShowErrorMessage = true, + disabled = false, + onChange, + onBlur, + values, + unit, + unitFieldName = '', + ...props +}: CompositionInputsProps) => { + return ( +
+ {mainLabel && } +
+
+ {inputsInfo.map(({ name, label }) => { + return ( + + ); + })} +
+ {shouldShowErrorMessage && error && ( +
+ + {error} +
+ )} +
+
+ ); +}; + +export default CompositionInputs; diff --git a/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss b/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss new file mode 100644 index 0000000000..5e64a8c5c2 --- /dev/null +++ b/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss @@ -0,0 +1,98 @@ +/* + * Copyright 2024 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 '../../../assets/mixin'; + +/*---------------------------------------- + NumberInputWithSelect +----------------------------------------*/ + +.inputWithSelectWrapper { + &:focus-within { + > div:nth-child(2) svg { + color: var(--Colors-Neutral-Neutral-600); + } + } + + &.hasError { + input { + color: var(--Colors-Accent---singles-Red-full); + } + + &:not(:focus-within) .selectWrapper { + border-color: var(--Colors-Accent---singles-Red-full); + } + } + + &.disabled { + input { + color: var(--Colors-Neutral-Neutral-300); + @include truncateText(); + } + } + + &:not(.disabled) .selectWrapper { + margin-right: -8px; + border-left: 1px solid var(--grey400); + } +} + +.ratioIcon { + margin-top: 4px; +} + +/*---------------------------------------- + Composition inputs +----------------------------------------*/ +.compositionInputsWrapper { + @include flex-column-gap(12px); +} + +.inputsWrapper { + display: flex; + gap: 24px; + flex-wrap: wrap; + + > div { + flex: 1 0 31%; // align up to 3 items in a row + } + + @include md-breakpoint { + flex-direction: column; + } +} + +.selectValue { + color: var(--Colors-Neutral-Neutral-600, #5d697e); +} + +.error { + display: flex; + align-items: center; + gap: 8px; + color: var(--Colors-Accent---singles-Red-full); + justify-content: center; + margin-top: 8px; + + .errorIcon { + width: 20px; + } + + @include md-breakpoint { + .errorMessage { + flex: 1; + } + } +} diff --git a/packages/webapp/src/components/Form/InFormButtons/index.tsx b/packages/webapp/src/components/Form/InFormButtons/index.tsx new file mode 100644 index 0000000000..91cfd69894 --- /dev/null +++ b/packages/webapp/src/components/Form/InFormButtons/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2024 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 clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import Button from '../Button'; +import { Info } from '../../Typography'; +import styles from './styles.module.scss'; + +export interface InFormButtonsProps { + isDisabled?: boolean; + statusText?: string; + confirmText: string; + informationalText?: string; + onCancel: () => void; + onConfirm?: () => void; + confirmButtonType?: 'button' | 'submit'; + className?: string; +} + +const InFormButtons = ({ + isDisabled, + statusText, + confirmText, + informationalText, + onCancel, + onConfirm, + confirmButtonType = 'button', + className, +}: InFormButtonsProps) => { + const { t } = useTranslation(); + + return ( +
+
+ {statusText && {statusText}} + + +
+ {informationalText && {informationalText}} +
+ ); +}; + +export default InFormButtons; diff --git a/packages/webapp/src/components/Form/InFormButtons/styles.module.scss b/packages/webapp/src/components/Form/InFormButtons/styles.module.scss new file mode 100644 index 0000000000..ad9ac4fe0a --- /dev/null +++ b/packages/webapp/src/components/Form/InFormButtons/styles.module.scss @@ -0,0 +1,65 @@ +/* + * Copyright 2024 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 '../../../assets/mixin'; + +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.statusText { + font-size: 16px; + color: var(--Colors-Neutral-Neutral-300); + + @include xs-breakpoint { + display: none; + } +} + +.informationalText { + margin-top: 0; + text-align: center; + color: var(--Colors-Neutral-Neutral-300); +} + +.buttons { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.button { + min-height: 32px; + height: 32px; + font-size: 14px; + display: inline-block; + + @include truncateText(); +} + +.confirmButton { + color: var(--Colors-Neutral-Neutral-900); + border: 1px solid var(--Colors-Neutral-Neutral-900); + + &:disabled { + color: var(--Colors-Neutral-Neutral-100); + border: 1px solid var(--Colors-Neutral-Neutral-100); + } +} diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx b/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx new file mode 100644 index 0000000000..fc762e387c --- /dev/null +++ b/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2024 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 { ReactElement, ReactNode, forwardRef } from 'react'; +import styles from './styles.module.scss'; +import clsx from 'clsx'; +import { HTMLInputProps } from '..'; + +export type InputBaseFieldProps = { + leftSection?: ReactNode; + mainSection?: ReactNode; + rightSection?: ReactNode; + isError?: boolean; + resetIcon?: ReactElement; + resetIconPosition?: 'left' | 'right'; +} & HTMLInputProps; + +const InputBaseField = forwardRef((props, ref) => { + const { + isError, + resetIcon, + resetIconPosition = 'right', + leftSection, + mainSection, + rightSection, + ...inputProps + } = props; + + return ( +
+ {leftSection && ( +
{leftSection}
+ )} + {mainSection || } + {(!!resetIcon || rightSection) && ( +
+ {rightSection} + {resetIcon} +
+ )} +
+ ); +}); + +export default InputBaseField; diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss b/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss new file mode 100644 index 0000000000..745510dfd6 --- /dev/null +++ b/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss @@ -0,0 +1,83 @@ +/* + * Copyright 2024 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 . + */ + +.inputWrapper .input { + all: unset; + height: 100%; + width: 100%; + flex: 1; +} + +.inputWrapper { + display: flex; + cursor: text; + width: 100%; + border: 1px solid var(--grey400); + box-sizing: border-box; + border-radius: 4px; + height: 48px; + padding: 0 8px; + font-size: 16px; + line-height: 24px; + color: var(--fontColor); + background-color: white; + font-family: 'Open Sans', 'SansSerif', serif; +} + +.inputDisabled { + background-color: var(--inputDisabled) !important; + color: var(--grey600); + border-color: var(--inputDefault); + cursor: not-allowed; + + > * { + pointer-events: none; + } +} + +.inputWrapper:focus-within { + border-color: var(--inputActive); +} + +.inputWrapper ::placeholder { + color: var(--grey500); +} + +.inputWrapper .input:focus::placeholder { + color: transparent; +} + +.inputError { + border-color: var(--error); +} + +.inputSection { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + cursor: default; + + &Left { + padding-right: 6px; + } + &Right { + padding-left: 6px; + + &.resetIconLeft { + flex-direction: row-reverse; + } + } +} diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx new file mode 100644 index 0000000000..47895f06a9 --- /dev/null +++ b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2024 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 Infoi from '../../../Tooltip/Infoi'; +import { ReactComponent as Leaf } from '../../../../assets/images/signUp/leaf.svg'; +import { Label } from '../../../Typography'; +import styles from './styles.module.scss'; +import { useTranslation } from 'react-i18next'; +import { ReactNode } from 'react'; + +export type InputBaseLabelProps = { + label?: string; + optional?: boolean; + hasLeaf?: boolean; + toolTipContent?: string; + icon?: ReactNode; + labelStyles?: React.CSSProperties; +}; + +export default function InputBaseLabel(props: InputBaseLabelProps) { + const { t } = useTranslation(); + + return ( +
+ + {props.toolTipContent && ( +
+ +
+ )} + {props.icon && {props.icon}} +
+ ); +} diff --git a/packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss similarity index 81% rename from packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss rename to packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss index 8a46f74ee6..fbc3bb2c7b 100644 --- a/packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss +++ b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss @@ -1,5 +1,5 @@ /* - * Copyright 2019, 2020, 2021, 2022, 2023 LiteFarm.org + * Copyright 2024 LiteFarm.org * This file is part of LiteFarm. * * LiteFarm is free software: you can redistribute it and/or modify @@ -13,18 +13,6 @@ * GNU General Public License for more details, see . */ -.sm { - margin-left: 8px; -} - -.container { - display: flex; - flex-direction: column; - overflow: visible; - position: relative; - min-width: 0; -} - .labelContainer { display: flex; justify-content: space-between; @@ -32,9 +20,8 @@ position: relative; } -.labelText { - position: absolute; - bottom: 0; +.sm { + margin-left: 8px; } .leaf { @@ -51,6 +38,7 @@ .icon { position: absolute; right: 0; + top: -8px; color: var(--iconDefault); } diff --git a/packages/webapp/src/components/Form/InputBase/index.tsx b/packages/webapp/src/components/Form/InputBase/index.tsx new file mode 100644 index 0000000000..b24521d342 --- /dev/null +++ b/packages/webapp/src/components/Form/InputBase/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2024 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 styles from './styles.module.scss'; +import { ComponentPropsWithoutRef, forwardRef } from 'react'; +import InputBaseLabel from './InputBaseLabel'; +import InputBaseField from './InputBaseField'; +import { Error, Info, TextWithExternalLink } from '../../Typography'; +import { Cross } from '../../Icons'; +import type { InputBaseFieldProps } from './InputBaseField'; +import type { InputBaseLabelProps } from './InputBaseLabel'; +import clsx from 'clsx'; + +export type HTMLInputProps = ComponentPropsWithoutRef<'input'>; + +// props meant to be shared with other similar input components +export type InputBaseSharedProps = InputBaseLabelProps & { + showResetIcon?: boolean; + showErrorText?: boolean; + onResetIconClick?: () => void; + info?: string; + error?: string; + link?: string; + textWithExternalLink?: string; + classes?: Partial< + Record<'input' | 'label' | 'container' | 'info' | 'errors', React.CSSProperties> + >; +} & Pick; + +type InputBaseProps = InputBaseSharedProps & + Pick & + HTMLInputProps; + +const InputBase = forwardRef((props, ref) => { + const { + label, + optional, + hasLeaf, + toolTipContent, + error, + info, + textWithExternalLink, + link, + icon, + leftSection, + mainSection, + rightSection, + showResetIcon = true, + showErrorText = true, + onResetIconClick, + classes, + className, + ...inputProps + } = props; + + return ( +
+ + {info && !error && {info}} + {showErrorText && error && ( + + {error} + + )} + {textWithExternalLink && link && ( + {textWithExternalLink} + )} +
+ ); +}); + +export default InputBase; diff --git a/packages/webapp/src/components/Form/InputBase/styles.module.scss b/packages/webapp/src/components/Form/InputBase/styles.module.scss new file mode 100644 index 0000000000..a95da5ed57 --- /dev/null +++ b/packages/webapp/src/components/Form/InputBase/styles.module.scss @@ -0,0 +1,22 @@ +/* + * Copyright 2024 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 . + */ + +.inputWrapper { + position: relative; +} + +.inputWrapper input:disabled + .stepper .stepperIcons { + pointer-events: none; +} diff --git a/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx b/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx new file mode 100644 index 0000000000..7ebf8d2d34 --- /dev/null +++ b/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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 styles from './stepper.module.scss'; +import { ReactComponent as IncrementIcon } from '../../../assets/images/number-input-increment.svg'; +import { ReactComponent as DecrementIcon } from '../../../assets/images/number-input-decrement.svg'; +import { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; +import clsx from 'clsx'; + +export type NumberInputStepperProps = { + increment: () => void; + decrement: () => void; + incrementDisabled: boolean; + decrementDisabled: boolean; +}; + +export default function NumberInputStepper(props: NumberInputStepperProps) { + return ( +
+ + + + + + + +
+ ); +} + +export function NumberInputStepperButton( + props: PropsWithChildren>, +) { + return ( + + ); +} diff --git a/packages/webapp/src/components/Form/NumberInput/index.tsx b/packages/webapp/src/components/Form/NumberInput/index.tsx new file mode 100644 index 0000000000..3e2333d2e0 --- /dev/null +++ b/packages/webapp/src/components/Form/NumberInput/index.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2024 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 { ReactNode } from 'react'; +import InputBase, { type InputBaseSharedProps } from '../InputBase'; +import NumberInputStepper from './NumberInputStepper'; +import useNumberInput, { NumberInputOptions } from './useNumberInput'; +import { FieldValues, UseControllerProps, useController, get } from 'react-hook-form'; + +export type NumberInputProps = UseControllerProps & + InputBaseSharedProps & + Omit & { + /** + * The currency symbol to display on left side of input + */ + currencySymbol?: ReactNode; + /** + * The unit to display on right side of input + */ + unit?: ReactNode; + /** + * Controls visibility of stepper. + */ + showStepper?: boolean; + + className?: string; + }; + +export default function NumberInput({ + locale, + useGrouping = true, + allowDecimal = true, + decimalDigits, + unit, + currencySymbol, + step = 1, + max = Infinity, + min = 0, + showStepper = false, + clampOnBlur = true, + name, + control, + rules, + defaultValue, + className, + onChange, + onBlur, + ...props +}: NumberInputProps) { + const { field, fieldState, formState } = useController({ name, control, rules, defaultValue }); + const { inputProps, reset, numericValue, increment, decrement } = useNumberInput({ + initialValue: get(formState.defaultValues, name) || defaultValue, + allowDecimal, + decimalDigits, + locale, + useGrouping, + step, + min, + max, + clampOnBlur, + onChange: (value) => { + field.onChange(isNaN(value) ? null : value); + onChange?.(value); + }, + onBlur: () => { + field.onBlur(); + onBlur?.(); + }, + }); + + return ( + + {unit} + {showStepper && ( + + )} + + } + /> + ); +} diff --git a/packages/webapp/src/components/Form/NumberInput/stepper.module.scss b/packages/webapp/src/components/Form/NumberInput/stepper.module.scss new file mode 100644 index 0000000000..a5ae4d81ef --- /dev/null +++ b/packages/webapp/src/components/Form/NumberInput/stepper.module.scss @@ -0,0 +1,52 @@ +/* + * Copyright 2024 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 . + */ + +.stepper { + display: flex; + flex-direction: column; + gap: 2px; + height: 24px; + width: 24px; +} + +.stepperBtnUnstyled { + all: unset; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + } +} + +.stepperBtn { + background-color: var(--Colors-Neutral-Neutral-50); + color: var(--Colors-Neutral-Neutral-600); + flex: 1; + display: flex; + justify-content: center; + align-items: center; + + &:first-child { + border-top-right-radius: 3px; + } + &:last-child { + border-bottom-right-radius: 3px; + } + + &:disabled { + background-color: #f9fafc; + color: #dadee5; + } +} diff --git a/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts b/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts new file mode 100644 index 0000000000..db9b31faac --- /dev/null +++ b/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2024 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 { useTranslation } from 'react-i18next'; +import { clamp, countDecimalPlaces, createNumberFormatter } from './utils'; +import { ChangeEvent, ComponentPropsWithRef, useMemo, useRef, useState } from 'react'; + +export type NumberInputOptions = { + /** + * Value shown on first render. + */ + initialValue?: number | null; + /** + * Controls grouping of numbers over 1000 with the thousands separator. + */ + useGrouping?: boolean; + /** + * Controls whether or not a decimal is allowed as input. If set to false, users can only enter whole numbers. + */ + allowDecimal?: boolean; + /** + * The locale to use for number formatting. + */ + locale?: string; + /** + * Number of decimal digits shown after blur. + */ + decimalDigits?: number; + /** + * - Amount to increment or decrement. + * - If allowDecimal is false, then step is rounded to the nearest whole number. + */ + step?: number; + /** + * - Maximum value of input. + * - If input value is greater than max then input value is clamped to max on blur. + * - If input value equals max then incrementing with stepper and keyboard is disabled. + */ + max?: number; + /** + * - Minimum value of input. + * - If input value is less than min then input value is clamped to min on blur. + * - If input value equals min then decrementing with stepper and keyboard is disabled. + */ + min?: number; + /** + * Controls whether or not to clamp value on blur that is outside of allowed range + */ + clampOnBlur?: boolean; + /** + * Function called when number value of input changes. + * @param value - Current value represented as number or NaN if input field is empty. + */ + onChange?: (value: number) => void; + /** + * Function called when input is blurred. + */ + onBlur?: () => void; +}; + +export default function useNumberInput({ + initialValue, + locale: customLocale, + decimalDigits, + allowDecimal = true, + useGrouping = true, + step = 1, + min = 0, + max = Infinity, + clampOnBlur = true, + onChange, + onBlur, +}: NumberInputOptions) { + const { + i18n: { language }, + } = useTranslation(); + + const locale = customLocale || language; + + const formatter = useMemo(() => { + const stepDecimalPlaces = countDecimalPlaces(step); + const options: Intl.NumberFormatOptions = { + useGrouping, + minimumFractionDigits: !allowDecimal ? undefined : decimalDigits ?? stepDecimalPlaces, + maximumFractionDigits: !allowDecimal ? 0 : decimalDigits ?? (stepDecimalPlaces || 20), + }; + + return createNumberFormatter(locale, options); + }, [locale, useGrouping, decimalDigits, step, allowDecimal]); + + const { decimalSeparator, thousandsSeparator } = useMemo(() => { + let separators = { + decimalSeparator: '.', + thousandsSeparator: ',', + }; + + // 11000.2 - random decimal number over 1000 used to extract thousands and decimal separator + const numberParts = createNumberFormatter(locale).formatToParts(11000.2); + for (let { type, value } of numberParts) { + if (type === 'decimal') { + separators.decimalSeparator = value; + } else if (type === 'group') { + separators.thousandsSeparator = value; + } + } + return separators; + }, [locale]); + + const [numericValue, setNumericValue] = useState(initialValue ?? NaN); + + // current input value that is focused and has been touched + const [touchedValue, setTouchedValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const stepValue = allowDecimal ? step : Math.round(step); + + const update = (next: number) => { + setNumericValue(next); + onChange?.(next); + }; + + const handleChange = (e: ChangeEvent) => { + const { value, validity } = e.target; + if (validity.patternMismatch) return; + setTouchedValue(value); + update(parseFloat(decimalSeparator === '.' ? value : value.replace(decimalSeparator, '.'))); + }; + + const handleBlur = () => { + if (clampOnBlur && (numericValue < min || numericValue > max)) { + update(clamp(numericValue, min, max)); + } + setIsFocused(false); + setTouchedValue(''); + onBlur?.(); + }; + + const pattern = useMemo(() => { + if (!isFocused) return; + if (!allowDecimal) return '[0-9]+'; + const decimalSeparatorRegex = `[${decimalSeparator === '.' ? '.' : `${decimalSeparator}.`}]`; + return `[0-9]*${decimalSeparatorRegex}?[0-9]*`; + }, [isFocused, allowDecimal, decimalSeparator]); + + const getDisplayValue = () => { + if (isNaN(numericValue)) return ''; + if (isFocused) + return ( + touchedValue || + (numericValue && formatter.format(numericValue).replaceAll(thousandsSeparator, '')) || + '' + ); + return formatter.format(numericValue); + }; + + const handleStep = (next: number) => { + // focus input when clicking on up/down button + if (!isFocused) inputRef.current?.focus(); + if (touchedValue) setTouchedValue(''); + update(clamp(next, Math.max(min, 0), max)); + }; + + const increment = () => handleStep((numericValue || 0) + stepValue); + const decrement = () => handleStep((numericValue || 0) - stepValue); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + // prevent cursor from shifting to start of input + e.preventDefault(); + increment(); + } else if (e.key === 'ArrowDown') { + decrement(); + } + }; + + const inputProps: ComponentPropsWithRef<'input'> = { + inputMode: 'decimal', + value: getDisplayValue(), + pattern, + onChange: handleChange, + onBlur: handleBlur, + onFocus: () => setIsFocused(true), + onKeyDown: handleKeyDown, + ref: inputRef, + }; + + return { + numericValue, + inputProps, + reset: () => update(initialValue ?? NaN), + clear: () => update(NaN), + update, + increment, + decrement, + }; +} diff --git a/packages/webapp/src/components/Form/NumberInput/utils.ts b/packages/webapp/src/components/Form/NumberInput/utils.ts new file mode 100644 index 0000000000..138ea18efc --- /dev/null +++ b/packages/webapp/src/components/Form/NumberInput/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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 function countDecimalPlaces(number: number) { + if (!Number.isFinite(number)) return 0; + let e = 1; + let decimalPlaces = 0; + while (Math.round(number * e) / e !== number) { + e *= 10; + decimalPlaces += 1; + } + return decimalPlaces; +} + +export function clamp(value: number, min: number, max: number) { + if (max < min) console.warn('clamp: max cannot be less than min'); + + return Math.min(Math.max(value, min), max); +} + +export function createNumberFormatter(locale: string, options?: Intl.NumberFormatOptions) { + try { + return new Intl.NumberFormat(locale, options); + } catch (error) { + // undefined will use browsers best matching locale + return new Intl.NumberFormat(undefined, options); + } +} diff --git a/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx b/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx new file mode 100644 index 0000000000..bb3871b351 --- /dev/null +++ b/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2024 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 React from 'react'; +import Select, { CreatableProps } from 'react-select/creatable'; +import { GroupBase, SelectInstance } from 'react-select'; +import { styles as baseStyles } from './styles'; +import InputBaseLabel, { InputBaseLabelProps } from '../InputBase/InputBaseLabel'; +import { useTranslation } from 'react-i18next'; +import { ClearIndicator, MultiValueRemove, MenuOpenDropdownIndicator } from './components'; +import scss from './styles.module.scss'; + +type CreatableSelectProps< + Option = unknown, + IsMulti extends boolean = false, + Group extends GroupBase