diff --git a/.env.sample b/.env.sample index 0cd7a16..df6d8fd 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,7 @@ +#build +TAG=latest +DOCKER_REGISTRY=openagri-eu +SOURCE_REPO=https://github.com/openagri-eu/openagri-pestanddiseasemanagement +## DB VITE_API_URL=https://gk.test.horizon-openagri.eu/api/ SERVICE_PORT=8007 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8ee9e76..2b30631 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,29 +1,25 @@ -name: Docker Image CI +name: Docker Multi-Platform Builds on: push: branches: [ main ] workflow_dispatch: - inputs: - branch: - description: 'Branch to run the workflow on' - required: false release: - types: - - created + types: [ created ] jobs: - build-and-push: + + # ------------------------- + # AMD64 Build + # ------------------------- + build-amd64: runs-on: ubuntu-latest permissions: contents: read packages: write - steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 # Saves time by not fetching other branches - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -35,16 +31,111 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set lowercase repository name + - name: Normalize repo and sanitize ref run: | echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV - - name: Build and push Docker image - id: docker_build + - name: Build and push AMD64 image uses: docker/build-push-action@v6 with: context: . + file: Dockerfile + build-args: SOURCE_REPO=https://github.com/${{ github.repository }} + platforms: linux/amd64 push: true - tags: ghcr.io/${{ env.REPO_LOWER }}:latest + tags: | + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-amd64 + ${{ github.ref_name == 'main' && format('ghcr.io/{0}:latest-amd64', env.REPO_LOWER) || '' }} cache-from: type=gha cache-to: type=gha,mode=max + + # ------------------------- + # ARM64 Build + # ------------------------- + build-arm64: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker daemon (containerd features) + uses: docker/setup-docker-action@v4 + with: + daemon-config: | + { + "debug": true, + "features": { "containerd-snapshotter": true } + } + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize repo and sanitize ref + run: | + echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV + + - name: Build and push ARM64 image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + build-args: BUILDKIT_PROGRESS=plain,SOURCE_REPO=https://github.com/${{ github.repository }} + platforms: linux/arm64 + push: true + tags: | + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-arm64 + ${{ github.ref_name == 'main' && format('ghcr.io/{0}:latest-arm64', env.REPO_LOWER) || '' }} + cache-from: type=gha,scope=arm64 + cache-to: type=gha,scope=arm64,mode=max + + # ------------------------- + # Create multi-arch manifest + # ------------------------- + manifest: + needs: [ build-amd64, build-arm64 ] + runs-on: ubuntu-latest + steps: + - name: Set up Docker Buildx (for imagetools) + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize repo and sanitize ref + run: | + echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + SAFE_REF=$(echo "${GITHUB_REF_NAME}" | tr / -) + echo "SAFE_REF=$SAFE_REF" >> $GITHUB_ENV + + - name: Create multi-arch manifest for ref tag + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }} \ + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-amd64 \ + ghcr.io/${{ env.REPO_LOWER }}:${{ env.SAFE_REF }}-arm64 + + - name: Also tag :latest on main + if: ${{ github.ref_name == 'main' }} + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ env.REPO_LOWER }}:latest \ + ghcr.io/${{ env.REPO_LOWER }}:latest-amd64 \ + ghcr.io/${{ env.REPO_LOWER }}:latest-arm64 diff --git a/Dockerfile b/Dockerfile index 6761b26..fc17463 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ +ARG SOURCE_REPO=https://github.com/openagri-eu/openagri-userdashboard + FROM node:22.17.1 AS builder +LABEL org.opencontainers.image.source=${SOURCE_REPO} # set working directory WORKDIR /app diff --git a/LICENSE b/LICENSE index 4153cd3..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,287 +1,201 @@ - EUROPEAN UNION PUBLIC LICENCE v. 1.2 - EUPL © the European Union 2007, 2016 - -This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined -below) which is provided under the terms of this Licence. Any use of the Work, -other than as authorised under this Licence is prohibited (to the extent such -use is covered by a right of the copyright holder of the Work). - -The Work is provided under the terms of this Licence when the Licensor (as -defined below) has placed the following notice immediately following the -copyright notice for the Work: - - Licensed under the EUPL - -or has expressed by any other means his willingness to license under the EUPL. - -1. Definitions - -In this Licence, the following terms have the following meaning: - -- ‘The Licence’: this Licence. - -- ‘The Original Work’: the work or software distributed or communicated by the - Licensor under this Licence, available as Source Code and also as Executable - Code as the case may be. - -- ‘Derivative Works’: the works or software that could be created by the - Licensee, based upon the Original Work or modifications thereof. This Licence - does not define the extent of modification or dependence on the Original Work - required in order to classify a work as a Derivative Work; this extent is - determined by copyright law applicable in the country mentioned in Article 15. - -- ‘The Work’: the Original Work or its Derivative Works. - -- ‘The Source Code’: the human-readable form of the Work which is the most - convenient for people to study and modify. - -- ‘The Executable Code’: any code which has generally been compiled and which is - meant to be interpreted by a computer as a program. - -- ‘The Licensor’: the natural or legal person that distributes or communicates - the Work under the Licence. - -- ‘Contributor(s)’: any natural or legal person who modifies the Work under the - Licence, or otherwise contributes to the creation of a Derivative Work. - -- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of - the Work under the terms of the Licence. - -- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, - renting, distributing, communicating, transmitting, or otherwise making - available, online or offline, copies of the Work or providing access to its - essential functionalities at the disposal of any other natural or legal - person. - -2. Scope of the rights granted by the Licence - -The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, -sublicensable licence to do the following, for the duration of copyright vested -in the Original Work: - -- use the Work in any circumstance and for all usage, -- reproduce the Work, -- modify the Work, and make Derivative Works based upon the Work, -- communicate to the public, including the right to make available or display - the Work or copies thereof to the public and perform publicly, as the case may - be, the Work, -- distribute the Work or copies thereof, -- lend and rent the Work or copies thereof, -- sublicense rights in the Work or copies thereof. - -Those rights can be exercised on any media, supports and formats, whether now -known or later invented, as far as the applicable law permits so. - -In the countries where moral rights apply, the Licensor waives his right to -exercise his moral right to the extent allowed by law in order to make effective -the licence of the economic rights here above listed. - -The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to -any patents held by the Licensor, to the extent necessary to make use of the -rights granted on the Work under this Licence. - -3. Communication of the Source Code - -The Licensor may provide the Work either in its Source Code form, or as -Executable Code. If the Work is provided as Executable Code, the Licensor -provides in addition a machine-readable copy of the Source Code of the Work -along with each copy of the Work that the Licensor distributes or indicates, in -a notice following the copyright notice attached to the Work, a repository where -the Source Code is easily and freely accessible for as long as the Licensor -continues to distribute or communicate the Work. - -4. Limitations on copyright - -Nothing in this Licence is intended to deprive the Licensee of the benefits from -any exception or limitation to the exclusive rights of the rights owners in the -Work, of the exhaustion of those rights or of other applicable limitations -thereto. - -5. Obligations of the Licensee - -The grant of the rights mentioned above is subject to some restrictions and -obligations imposed on the Licensee. Those obligations are the following: - -Attribution right: The Licensee shall keep intact all copyright, patent or -trademarks notices and all notices that refer to the Licence and to the -disclaimer of warranties. The Licensee must include a copy of such notices and a -copy of the Licence with every copy of the Work he/she distributes or -communicates. The Licensee must cause any Derivative Work to carry prominent -notices stating that the Work has been modified and the date of modification. - -Copyleft clause: If the Licensee distributes or communicates copies of the -Original Works or Derivative Works, this Distribution or Communication will be -done under the terms of this Licence or of a later version of this Licence -unless the Original Work is expressly distributed only under this version of the -Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee -(becoming Licensor) cannot offer or impose any additional terms or conditions on -the Work or Derivative Work that alter or restrict the terms of the Licence. - -Compatibility clause: If the Licensee Distributes or Communicates Derivative -Works or copies thereof based upon both the Work and another work licensed under -a Compatible Licence, this Distribution or Communication can be done under the -terms of this Compatible Licence. For the sake of this clause, ‘Compatible -Licence’ refers to the licences listed in the appendix attached to this Licence. -Should the Licensee's obligations under the Compatible Licence conflict with -his/her obligations under this Licence, the obligations of the Compatible -Licence shall prevail. - -Provision of Source Code: When distributing or communicating copies of the Work, -the Licensee will provide a machine-readable copy of the Source Code or indicate -a repository where this Source will be easily and freely available for as long -as the Licensee continues to distribute or communicate the Work. - -Legal Protection: This Licence does not grant permission to use the trade names, -trademarks, service marks, or names of the Licensor, except as required for -reasonable and customary use in describing the origin of the Work and -reproducing the content of the copyright notice. - -6. Chain of Authorship - -The original Licensor warrants that the copyright in the Original Work granted -hereunder is owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each Contributor warrants that the copyright in the modifications he/she brings -to the Work are owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each time You accept the Licence, the original Licensor and subsequent -Contributors grant You a licence to their contributions to the Work, under the -terms of this Licence. - -7. Disclaimer of Warranty - -The Work is a work in progress, which is continuously improved by numerous -Contributors. It is not a finished work and may therefore contain defects or -‘bugs’ inherent to this type of development. - -For the above reason, the Work is provided under the Licence on an ‘as is’ basis -and without warranties of any kind concerning the Work, including without -limitation merchantability, fitness for a particular purpose, absence of defects -or errors, accuracy, non-infringement of intellectual property rights other than -copyright as stated in Article 6 of this Licence. - -This disclaimer of warranty is an essential part of the Licence and a condition -for the grant of any rights to the Work. - -8. Disclaimer of Liability - -Except in the cases of wilful misconduct or damages directly caused to natural -persons, the Licensor will in no event be liable for any direct or indirect, -material or moral, damages of any kind, arising out of the Licence or of the use -of the Work, including without limitation, damages for loss of goodwill, work -stoppage, computer failure or malfunction, loss of data or any commercial -damage, even if the Licensor has been advised of the possibility of such damage. -However, the Licensor will be liable under statutory product liability laws as -far such laws apply to the Work. - -9. Additional agreements - -While distributing the Work, You may choose to conclude an additional agreement, -defining obligations or services consistent with this Licence. However, if -accepting obligations, You may act only on your own behalf and on your sole -responsibility, not on behalf of the original Licensor or any other Contributor, -and only if You agree to indemnify, defend, and hold each Contributor harmless -for any liability incurred by, or claims asserted against such Contributor by -the fact You have accepted any warranty or additional liability. - -10. Acceptance of the Licence - -The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ -placed under the bottom of a window displaying the text of this Licence or by -affirming consent in any other similar way, in accordance with the rules of -applicable law. Clicking on that icon indicates your clear and irrevocable -acceptance of this Licence and all of its terms and conditions. - -Similarly, you irrevocably accept this Licence and all of its terms and -conditions by exercising any rights granted to You by Article 2 of this Licence, -such as the use of the Work, the creation by You of a Derivative Work or the -Distribution or Communication by You of the Work or copies thereof. - -11. Information to the public - -In case of any Distribution or Communication of the Work by means of electronic -communication by You (for example, by offering to download the Work from a -remote location) the distribution channel or media (for example, a website) must -at least provide to the public the information requested by the applicable law -regarding the Licensor, the Licence and the way it may be accessible, concluded, -stored and reproduced by the Licensee. - -12. Termination of the Licence - -The Licence and the rights granted hereunder will terminate automatically upon -any breach by the Licensee of the terms of the Licence. - -Such a termination will not terminate the licences of any person who has -received the Work from the Licensee under the Licence, provided such persons -remain in full compliance with the Licence. - -13. Miscellaneous - -Without prejudice of Article 9 above, the Licence represents the complete -agreement between the Parties as to the Work. - -If any provision of the Licence is invalid or unenforceable under applicable -law, this will not affect the validity or enforceability of the Licence as a -whole. Such provision will be construed or reformed so as necessary to make it -valid and enforceable. - -The European Commission may publish other linguistic versions or new versions of -this Licence or updated versions of the Appendix, so far this is required and -reasonable, without reducing the scope of the rights granted by the Licence. New -versions of the Licence will be published with a unique version number. - -All linguistic versions of this Licence, approved by the European Commission, -have identical value. Parties can take advantage of the linguistic version of -their choice. - -14. Jurisdiction - -Without prejudice to specific agreement between parties, - -- any litigation resulting from the interpretation of this License, arising - between the European Union institutions, bodies, offices or agencies, as a - Licensor, and any Licensee, will be subject to the jurisdiction of the Court - of Justice of the European Union, as laid down in article 272 of the Treaty on - the Functioning of the European Union, - -- any litigation arising between other parties and resulting from the - interpretation of this License, will be subject to the exclusive jurisdiction - of the competent court where the Licensor resides or conducts its primary - business. - -15. Applicable Law - -Without prejudice to specific agreement between parties, - -- this Licence shall be governed by the law of the European Union Member State - where the Licensor has his seat, resides or has his registered office, - -- this licence shall be governed by Belgian law if the Licensor has no seat, - residence or registered office inside a European Union Member State. - -Appendix - -‘Compatible Licences’ according to Article 5 EUPL are: - -- GNU General Public License (GPL) v. 2, v. 3 -- GNU Affero General Public License (AGPL) v. 3 -- Open Software License (OSL) v. 2.1, v. 3.0 -- Eclipse Public License (EPL) v. 1.0 -- CeCILL v. 2.0, v. 2.1 -- Mozilla Public Licence (MPL) v. 2 -- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 -- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for - works other than software -- European Union Public Licence (EUPL) v. 1.1, v. 1.2 -- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong - Reciprocity (LiLiQ-R+). - -The European Commission may update this Appendix to later versions of the above -licences without producing a new version of the EUPL, as long as they provide -the rights granted in Article 2 of this Licence and protect the covered Source -Code from exclusive appropriation. - -All other changes or additions to this Appendix require the production of a new -EUPL version. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index e5b6af4..5d468f6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Progressive web apps are typically built on top of web technologies such as HTML In the case of the OpenAgri project the React open-source front-end JavaScript library will be used. It should be noted that React supports the development of PWA. # License -This project code is licensed under the EUPL 1.2 license, see the LICENSE file for more details. +This project code is licensed under the Apache 2.0 license, see the LICENSE file for more details. Please note that each service may have different licenses, which can be found their specific source code repository. # Prerequisites @@ -47,3 +47,46 @@ docker run --rm -it -p 80:80 openagri-dashboard ``` You can now visit the page [http://127.0.0.1](http://127.0.0.1) to access the WEB Dashboard + +# Using as a PWA +The dashboard is a Progressive Web App, so it can be installed and run like a native app on desktop and mobile, with offline support for the app shell. + +A service worker is registered automatically (`registerType: 'autoUpdate'`), so an installed app silently updates to the latest version on the next launch. + +### Runtime caching +The service worker also caches API responses to keep the dashboard usable on poor or absent network. Strategies per endpoint group: + +| Cache | Strategy | TTL | Scope | +|---|---|---|---| +| `oa-reference` | StaleWhileRevalidate | 7 days | Global catalogs (activity types, crops, diseases, threat models, soil types, ETo option types) | +| `oa-user-semi` | NetworkFirst (5s timeout) | 1 day | User-scoped semi-static (Farm, FarmParcels, FarmAnimals, AgriculturalMachines, Pesticides, Fertilizers, `me/`) | +| `oa-user-live` | NetworkFirst (3s timeout) | 1 hour | User-scoped live data (FarmCalendarActivities, Observations, Alerts, CompostOperations, IrrigationOperations, irrigation datasets/calculations) | +| `oa-forecast` | CacheFirst | 30 min | Weather/risk forecasts | + +User-scoped caches (`oa-user-*`) are wiped on sign-out to prevent cross-user data leak on the same device. Reference and forecast caches survive. + +> **Offline writes are NOT supported yet.** Mutations (POST/PATCH/DELETE) bypass the service worker entirely — they fail without a live connection. A future round will add Workbox Background Sync to queue mutations and replay them on reconnect. + +An `OfflineChip` appears in the toolbar whenever `navigator.onLine` reports offline, with a tooltip explaining that data shown may be stale and submissions will fail. + +> **Note:** Installing requires a secure context — HTTPS, or `localhost` during local development. Plain `http://` over the LAN will not offer installation. + +### Install +- **Desktop (Chrome/Edge):** click the install icon at the right of the address bar, or open the ⋮ menu → *Install OpenAgri-UserDashboard…* +- **Android (Chrome):** ⋮ menu → *Install app* / *Add to Home screen* +- **iOS (Safari):** Share → *Add to Home Screen* + +The installed app launches in its own standalone window using the OpenAgri logo as its icon. + +### Regenerating icons +App icons (favicon, Apple touch icon, and the `pwa-*`/`maskable` PNGs) are generated from `public/logo.png`. After changing that image, regenerate them with: + +``` +npm run generate-pwa-assets +``` + +### Testing on a mobile device +The production build (`npm run build` + `npm run preview`) serves the service worker reliably (the dev server's is best-effort). To install on a phone you need an HTTPS origin: + +- **Android:** Chrome `chrome://inspect` → *Port forwarding* maps the phone's `localhost:4173` to your machine — this counts as a secure context and keeps the same origin the backend expects. +- **Any device:** expose the preview over an HTTPS tunnel, e.g. `npx cloudflared tunnel --url http://localhost:4173`. Tunnel hostnames are already allowed in `vite.config.ts` (`preview.allowedHosts`). Note that API calls still require the tunnel origin to be on the backend's CORS allow-list. diff --git a/docker-compose.yml b/docker-compose.yml index c39ddab..5752b17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ services: web: - build: . - image: ghcr.io/openagri-eu/openagri-userdashboard:latest + build: + context: . + args: + SOURCE_REPO: ${SOURCE_REPO} + image: ghcr.io/${DOCKER_REGISTRY}/openagri-userdashboard:${TAG} ports: - "${SERVICE_PORT}:${SERVICE_PORT}" environment: diff --git a/index.html b/index.html index 22abaee..4951e47 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,8 @@ - OpenAgri-UserDashboard + TS + + OpenAgri-UserDashboard diff --git a/package-lock.json b/package-lock.json index e8d6677..95877bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,16 +16,23 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@highcharts/react": "^4.1.0", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", - "dayjs": "^1.11.13", + "@types/react-window": "^1.8.8", + "dayjs": "^1.11.19", + "dayjs-plugin-utc": "^0.1.2", + "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", + "mui-color-input": "^9.0.0", + "ol": "^10.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", + "react-window": "^1.8.11", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { @@ -1622,6 +1629,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -2434,6 +2450,23 @@ "@fullcalendar/core": "~6.1.19" } }, + "node_modules/@highcharts/react": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@highcharts/react/-/react-4.1.0.tgz", + "integrity": "sha512-sgGR8rAmx3tet8fP9bWUhVvt5nOuw6A0Ybby+q463g/9zQOmoA0wJ3TwLmKJm881UwN7++kkiMwoH4AeuMKsaQ==", + "license": "See license in LICENSE", + "peerDependencies": { + "@types/react": ">=18", + "highcharts": ">=12.0.0", + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3059,6 +3092,12 @@ "node": ">= 8" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3496,6 +3535,12 @@ } } }, + "node_modules/@toolpad/core/node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/@toolpad/utils": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@toolpad/utils/-/utils-0.16.0.tgz", @@ -3677,6 +3722,12 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -3705,6 +3756,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4034,6 +4094,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@zarrita/storage": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", + "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "2.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4975,9 +5045,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/dayjs-plugin-utc": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dayjs-plugin-utc/-/dayjs-plugin-utc-0.1.2.tgz", + "integrity": "sha512-ExERH5o3oo6jFOdkvMP3gytTCQ9Ksi5PtylclJWghr7k7m3o2U5QrwtdiJkOxLOH4ghr0EKhpqGefzGz1VvVJg==", "license": "MIT" }, "node_modules/debug": { @@ -5156,6 +5232,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -5724,6 +5806,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5938,6 +6026,25 @@ "node": ">=6.9.0" } }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6211,6 +6318,12 @@ "node": ">= 0.4" } }, + "node_modules/highcharts": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/highcharts/-/highcharts-12.4.0.tgz", + "integrity": "sha512-o6UxxfChSUrvrZUbWrAuqL1HO/+exhAUPcZY6nnqLsadZQlnP16d082sg7DnXKZCk1gtfkyfkp6g3qkIZ9miZg==", + "license": "https://www.highcharts.com/license" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7024,6 +7137,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7140,6 +7259,12 @@ "node": ">= 0.4" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7231,6 +7356,28 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mui-color-input": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/mui-color-input/-/mui-color-input-9.0.0.tgz", + "integrity": "sha512-DNuLS+LCbtgSvj0CmG0z7Ge6i+cVporDKe4cZnckJZFMdz3Mkxnnk1s89vvXp5J+EvrmYWdkI3qsXdkAOzJw0Q==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0" + }, + "peerDependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/material": "^7.0.0 || ^9.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7331,6 +7478,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7384,6 +7540,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ol": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", + "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^3.0.5 || ^3.1.0-beta.0", + "pbf": "4.0.1", + "rbush": "^4.0.0", + "zarrita": "^0.7.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7489,6 +7663,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7501,6 +7681,12 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7569,6 +7755,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7749,6 +7947,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -7791,6 +7995,24 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7801,6 +8023,15 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7918,6 +8149,23 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -7933,6 +8181,12 @@ "node": ">= 6" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8093,6 +8347,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9296,6 +9559,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unzipit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", + "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -9517,6 +9789,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -10005,6 +10283,12 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10055,6 +10339,22 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zarrita": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", + "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.2.0", + "numcodecs": "^0.3.2" + } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" } } } diff --git a/package.json b/package.json index 52f281d..c460468 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "generate-pwa-assets": "pwa-assets-generator" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -18,16 +19,23 @@ "@fullcalendar/interaction": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@highcharts/react": "^4.1.0", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@mui/x-charts": "^8.11.1", "@mui/x-date-pickers": "^8.10.2", "@toolpad/core": "^0.16.0", - "dayjs": "^1.11.13", + "@types/react-window": "^1.8.8", + "dayjs": "^1.11.19", + "dayjs-plugin-utc": "^0.1.2", + "highcharts": "^12.4.0", "jwt-decode": "^4.0.0", + "mui-color-input": "^9.0.0", + "ol": "^10.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.3", + "react-window": "^1.8.11", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000..b6e2268 Binary files /dev/null and b/public/apple-touch-icon-180x180.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..ea64619 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..ea5f8f7 Binary files /dev/null and b/public/logo.png differ diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png new file mode 100644 index 0000000..c013298 Binary files /dev/null and b/public/maskable-icon-512x512.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000..aafeee7 Binary files /dev/null and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000..3943fa5 Binary files /dev/null and b/public/pwa-512x512.png differ diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png new file mode 100644 index 0000000..f1d2248 Binary files /dev/null and b/public/pwa-64x64.png differ diff --git a/public/screenshot-mobile.png b/public/screenshot-mobile.png new file mode 100644 index 0000000..ba49f74 Binary files /dev/null and b/public/screenshot-mobile.png differ diff --git a/public/screenshot-wide.png b/public/screenshot-wide.png new file mode 100644 index 0000000..76dc5db Binary files /dev/null and b/public/screenshot-wide.png differ diff --git a/pwa-assets.config.ts b/pwa-assets.config.ts index 452b31f..592963d 100644 --- a/pwa-assets.config.ts +++ b/pwa-assets.config.ts @@ -8,5 +8,5 @@ export default defineConfig({ preset: '2023', }, preset, - images: ['public/favicon.svg'], + images: ['public/logo.png'], }) diff --git a/src/App.tsx b/src/App.tsx index ffaa599..e027e1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import WaterDropIcon from '@mui/icons-material/WaterDrop'; import PestControlIcon from '@mui/icons-material/PestControl'; import AssessmentIcon from '@mui/icons-material/Assessment'; // TODO: possibly change to something else import ThermostatIcon from '@mui/icons-material/Thermostat'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import SessionContext, { Session } from '@contexts/SessionContext'; import { Folder } from '@mui/icons-material'; import FenceIcon from '@mui/icons-material/Fence'; @@ -23,130 +23,8 @@ import CrueltyFreeIcon from '@mui/icons-material/CrueltyFree'; import ReportIcon from '@mui/icons-material/Report'; import CoronavirusIcon from '@mui/icons-material/Coronavirus'; import GrassIcon from '@mui/icons-material/Grass'; - -const NAVIGATION: Navigation = [ - { - kind: 'header', - title: 'Home page', - }, - { - segment: '', - title: 'OpenAgri Dashboard', - icon: , - }, - { - kind: 'header', - title: 'Farm calendar', - }, - // { - // segment: 'farm-calendar', - // title: 'Farm calendar', - // icon: , - // children: [ - { - segment: 'farm-calendar', - title: 'Farm calendar', - icon: , - }, - { - segment: 'farm-locations', - title: 'Farm locations', - icon: , - children: [ - { - segment: 'farms', - title: 'Farms', - icon: , - }, - { - segment: 'farm-parcels', - title: 'Farm parcels', - icon: , - } - ] - }, - { - segment: 'reporting-service', - title: 'Reporting service', - icon: , - children: [ - { - segment: 'compost-operations', - title: 'Compost operations', - icon: , - }, - { - segment: 'farm-animals', - title: 'Farm animals', - icon: , - }, - { - segment: 'irrigation-operations', - title: 'Irrigation operations', - icon: , - }, - ] - }, - // ] - // }, - { - kind: 'header', - title: 'Irrigation management', - }, - // { - // segment: 'wkt-input', - // title: 'WKT input', - // icon: , - // }, - { - segment: 'eto-calculator', - title: 'ETo Calculator', - icon: , - }, - { - segment: 'upload-dataset', - title: 'Upload Dataset', - icon: , - }, - { - segment: 'soil-moisture-analysis', - title: 'Soil Moisture Analysis', - icon: , - }, - { - kind: 'header', - title: 'Pest and disease management', - }, - { - segment: 'pests', - title: 'Pests', - icon: , - }, - { - segment: 'gdd', - title: 'Growing degree days', - icon: , - }, - { - segment: 'diseases', - title: 'Diseases', - icon: , - }, - { - segment: 'risk-index', - title: 'Risk index', - icon: , - }, - { - kind: 'header', - title: 'Weather data', - }, - { - segment: 'weather-data', - title: 'Weather data', - icon: , - }, -]; +import YardIcon from '@mui/icons-material/Yard'; +import TerrainIcon from '@mui/icons-material/Terrain'; const BRANDING = { logo: , @@ -167,8 +45,164 @@ export default function App() { } }, [session]); + const navigation = useMemo(() => { + const hasAccess = (code: string) => { + if (!session || !session.services) return false; + const service = session.services.find(s => s.code === code); + return service ? service.actions.includes('view') : false; + }; + + const hasAccessIrriUploadDataset = () => { + if (!session || !session.services) return false; + const service = session.services.find(s => s.code === 'IRM'); + return service ? service.actions.includes('add') : false; + } + + const nav: any[] = [ + { + kind: 'header', + title: 'Home page', + }, + { + segment: '', + title: 'OpenAgri Dashboard', + icon: , + }, + { + kind: 'header', + title: 'Farm calendar', + }, + { + segment: 'farm-calendar', + title: 'Farm calendar', + icon: , + disabled: !hasAccess('FC'), + }, + { + segment: 'farm-locations', + title: 'Farm locations', + icon: , + disabled: !hasAccess('FC'), + children: [ + { + segment: 'farms', + title: 'Farms', + icon: , + disabled: !hasAccess('FC'), + }, + { + segment: 'farm-parcels', + title: 'Farm parcels', + icon: , + disabled: !hasAccess('FC'), + } + ] + }, + { + segment: 'reporting-service', + title: 'Reporting service', + icon: , + disabled: !hasAccess('RP'), + children: [ + { + segment: 'compost-operations', + title: 'Compost operations', + icon: , + disabled: !hasAccess('RP'), + }, + { + segment: 'farm-animals', + title: 'Farm animals', + icon: , + disabled: !hasAccess('RP'), + }, + { + segment: 'irrigation-operations', + title: 'Irrigation operations', + icon: , + disabled: !hasAccess('RP'), + }, + ] + }, + { + kind: 'header', + title: 'Irrigation management', + }, + { + segment: 'eto-calculator', + title: 'ETo Calculator', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'upload-dataset', + title: 'Upload Dataset', + icon: , + disabled: !hasAccess('IRM') || !hasAccessIrriUploadDataset(), + }, + { + segment: 'soil-moisture-analysis', + title: 'Soil Moisture Analysis', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'crop-types', + title: 'Crop types', + icon: , + disabled: !hasAccess('IRM'), + }, + { + segment: 'soil-types', + title: 'Soil types', + icon: , + disabled: !hasAccess('IRM'), + }, + { + kind: 'header', + title: 'Pest and disease management', + }, + { + segment: 'pests', + title: 'Pests', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'gdd', + title: 'Growing degree days', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'threat-models', + title: 'Threat models', + icon: , + disabled: !hasAccess('PDM'), + }, + { + segment: 'risk-forecast', + title: 'Risk forecast', + icon: , + disabled: !hasAccess('PDM'), + }, + { + kind: 'header', + title: 'Weather data', + }, + { + segment: 'weather-data', + title: 'Weather data', + icon: , + disabled: !hasAccess('WD'), + }, + ]; + + return nav as Navigation; + }, [session]); + return ( - + diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx index 835a575..1f3b2bf 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.tsx @@ -1,57 +1,234 @@ -import { alpha, Box, Typography, useTheme } from "@mui/material"; +import { alpha, Box, IconButton, InputAdornment, TextField, Typography, useMediaQuery, useTheme } from "@mui/material"; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useEffect, useMemo, useRef, useState } from "react"; +import { VariableSizeList, ListChildComponentProps } from 'react-window'; +import { FarmParcelModel } from "@models/FarmParcel"; import { ParcelSelectionListProps } from "./ParcelSelectionList.types"; import placeholder from '/parcel-placeholder.png'; -const ParcelSelectionList: React.FC = ({ parcels, selectedParcelId, f }) => { +interface FarmHeaderItem { + type: 'farm-header'; + farmId: string; + farmName: string; + parcelCount: number; + collapsed: boolean; +} + +interface ParcelRowItem { + type: 'parcel'; + parcel: FarmParcelModel; +} + +type Item = FarmHeaderItem | ParcelRowItem; + +const ROW_HEIGHT_DESKTOP = 112; +const ROW_HEIGHT_MOBILE = 80; +const HEADER_HEIGHT = 56; +const LIST_HEIGHT_DESKTOP = 480; +const LIST_HEIGHT_MOBILE = 360; + +const UNKNOWN_FARM = '__unknown__'; + +const matchesSearch = (p: FarmParcelModel, q: string, farmName: string): boolean => { + if (!q) return true; + const hay = [ + p.identifier, p.category, p.inRegion, p.hasToponym, p.description, farmName, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q.toLowerCase()); +}; + +const ParcelSelectionList: React.FC = ({ parcels, selectedParcelId, f, farmNamesById = {} }) => { const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const ROW_HEIGHT = isMobile ? ROW_HEIGHT_MOBILE : ROW_HEIGHT_DESKTOP; + const LIST_HEIGHT = isMobile ? LIST_HEIGHT_MOBILE : LIST_HEIGHT_DESKTOP; + const [search, setSearch] = useState(''); + const [collapsedFarms, setCollapsedFarms] = useState>(new Set()); + const listRef = useRef(null); - return ( - parcels.map((p) => { - const isSelected = p["@id"] === selectedParcelId; + const toggleFarm = (id: string) => { + setCollapsedFarms(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const items = useMemo(() => { + const grouped = new Map(); + for (const p of parcels) { + const fid = p.farm?.["@id"] ?? UNKNOWN_FARM; + const farmName = fid === UNKNOWN_FARM ? '(no farm)' : (farmNamesById[fid] ?? 'Unknown farm'); + if (!matchesSearch(p, search, farmName)) continue; + if (!grouped.has(fid)) grouped.set(fid, []); + grouped.get(fid)!.push(p); + } + + const sortedFarmIds = Array.from(grouped.keys()).sort((a, b) => { + const na = a === UNKNOWN_FARM ? '' : (farmNamesById[a] ?? a); + const nb = b === UNKNOWN_FARM ? '' : (farmNamesById[b] ?? b); + return na.localeCompare(nb); + }); + + const out: Item[] = []; + const isSearching = !!search.trim(); + for (const fid of sortedFarmIds) { + const collapsed = !isSearching && collapsedFarms.has(fid); + const farmName = fid === UNKNOWN_FARM ? '(no farm)' : (farmNamesById[fid] ?? 'Unknown farm'); + const farmParcels = grouped.get(fid)!; + out.push({ + type: 'farm-header', + farmId: fid, + farmName, + parcelCount: farmParcels.length, + collapsed, + }); + if (!collapsed) { + for (const p of farmParcels) { + out.push({ type: 'parcel', parcel: p }); + } + } + } + return out; + }, [parcels, search, collapsedFarms, farmNamesById]); + + const getItemSize = (index: number) => items[index].type === 'farm-header' ? HEADER_HEIGHT : ROW_HEIGHT; + + useEffect(() => { + listRef.current?.resetAfterIndex(0); + }, [items]); + + const Row = ({ index, style }: ListChildComponentProps) => { + const item = items[index]; + if (item.type === 'farm-header') { return ( toggleFarm(item.farmId)} sx={{ display: 'flex', alignItems: 'center', - padding: 2, + gap: 1, + padding: 1.5, + cursor: 'pointer', + boxSizing: 'border-box', + width: '100%', + overflow: 'hidden', + backgroundColor: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, + '&:hover': { backgroundColor: alpha(theme.palette.primary.main, 0.05) }, + }} + > + {item.collapsed ? : } + + {item.farmName} + + + {item.parcelCount} parcel{item.parcelCount === 1 ? '' : 's'} + + + ); + } + + const p = item.parcel; + const isSelected = p["@id"] === selectedParcelId; + const parcelImage = (p.depiction || '').trim() || placeholder; + return ( + + f(p)} + sx={{ + display: 'flex', + alignItems: 'center', + padding: { xs: 1, sm: 2 }, boxShadow: 2, borderRadius: 2, cursor: 'pointer', + height: '100%', + boxSizing: 'border-box', + width: '100%', + overflow: 'hidden', transition: 'background-color 200ms cubic-bezier(0.4, 0, 0.2, 1)', - backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.08) : 'white', + backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.08) : theme.palette.background.paper, '&:hover': { backgroundColor: isSelected ? alpha(theme.palette.primary.main, 0.2) : alpha('rgb(0, 0, 0)', 0.04), } }} - onClick={() => f(p)} > - - - + + {p.identifier} - - {p["@type"]} + + {[p.category, p.inRegion].filter(Boolean).join(' · ') || p["@type"]} - ); - }) + + ); + }; + + if (parcels.length === 0) { + return No parcels available.; + } + + return ( + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: search ? ( + + setSearch('')} aria-label="clear"> + + + + ) : undefined, + } + }} + /> + {items.length === 0 ? ( + No matching parcels. + ) : ( + acc + getItemSize(i), 0))} + itemCount={items.length} + itemSize={getItemSize} + width="100%" + style={{ overflowX: 'hidden' }} + > + {Row} + + )} + ); }; -export default ParcelSelectionList; \ No newline at end of file +export default ParcelSelectionList; diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts index 26ef498..fece62c 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionList/ParcelSelectionList.types.ts @@ -4,4 +4,5 @@ export interface ParcelSelectionListProps { parcels: FarmParcelModel[]; selectedParcelId?: string; f: (parcel?: FarmParcelModel) => void; -} \ No newline at end of file + farmNamesById?: Record; +} diff --git a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx index 653e694..cfbeaeb 100644 --- a/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx +++ b/src/components/dashboard/ParcelSelectionModule/ParcelSelectionModule.tsx @@ -1,8 +1,9 @@ import useFetch from "@hooks/useFetch"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import GenericSnackbar from "../../shared/GenericSnackbar/GenericSnackbar"; import useSnackbar from "@hooks/useSnackbar"; import { FarmParcelModel } from "@models/FarmParcel"; +import { FarmModel } from "@models/Farm"; import { useSession } from "@contexts/SessionContext"; import { Box, Button, Skeleton } from "@mui/material"; import Accordion from '@mui/material/Accordion'; @@ -16,6 +17,7 @@ import ClearIcon from '@mui/icons-material/Clear'; const ParcelSelectionModule = () => { const [parcels, setParcels] = useState([]); + const [farms, setFarms] = useState([]); const { session, setSession } = useSession(); const [expanded, setExpanded] = useState(!session?.farm_parcel); @@ -26,12 +28,28 @@ const ParcelSelectionModule = () => { } ); + const { fetchData: fetchFarms, response: farmsResponse } = useFetch( + "proxy/farmcalendar/api/v1/Farm/?format=json", + { method: 'GET' }, + ); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); useEffect(() => { - fetchData() + fetchData(); + fetchFarms(); }, []) + useEffect(() => { + if (Array.isArray(farmsResponse)) setFarms(farmsResponse); + }, [farmsResponse]) + + const farmNamesById = useMemo(() => { + const map: Record = {}; + for (const f of farms) map[f["@id"]] = f.name; + return map; + }, [farms]) + useEffect(() => { if (error) { showSnackbar('error', 'Error loading parcel list'); @@ -85,7 +103,7 @@ const ParcelSelectionModule = () => { - + + + + + + {parcels.length === 0 && ( + No parcels found. + )} + {parcels.map(p => { + const id = p["@id"].split(':').pop() ?? ''; + return ( + toggleParcel(id)} + disabled={running} + /> + } + label={`${p.identifier} (${p.category})`} + /> + ); + })} + + + + {(running || done) && ( + + + + {done ? 'Done' : 'Caching...'} + + + {progress.done} / {progress.total} + + + + + )} + + {failures.length > 0 && ( + + + Failed endpoints ({failures.length}) + + + {failures.map((f, i) => ( + + + + ))} + + + )} + + + + + {done && failures.length > 0 && ( + + )} + {!done && ( + + )} + + + ); +}; + +export default PrepareOfflineModal; diff --git a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx index 72b0455..e8bc670 100644 --- a/src/components/dashboard/ToolbarActions/ToolbarActions.tsx +++ b/src/components/dashboard/ToolbarActions/ToolbarActions.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemIcon, ListItemText, MenuItem, MenuList, Paper, Popover, Tooltip } from "@mui/material"; +import { IconButton, ListItemIcon, ListItemText, MenuItem, MenuList, Paper, Popover, Stack, Tooltip } from "@mui/material"; import { useCallback, useEffect, useState } from "react"; import PersonIcon from '@mui/icons-material/Person'; @@ -6,6 +6,9 @@ import LogoutIcon from '@mui/icons-material/Logout'; import { useNavigate } from "react-router-dom"; import { useSession } from "@contexts/SessionContext"; import useFetch from "@hooks/useFetch"; +import { clearUserCaches } from "@utils/pwaCache"; +import OfflineChip from "@components/shared/OfflineChip/OfflineChip"; +import PrepareOfflineButton from "@components/dashboard/PrepareOffline/PrepareOfflineButton"; const ToolbarActions = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -33,8 +36,10 @@ const ToolbarActions = () => { useEffect(() => { if (response || error) { - setSession(null); - navigate("/"); + clearUserCaches().finally(() => { + setSession(null); + navigate("/"); + }); } }, [response, error]); @@ -43,7 +48,9 @@ const ToolbarActions = () => { } return ( - <> + + +
@@ -79,7 +86,7 @@ const ToolbarActions = () => { - + ); } diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx new file mode 100644 index 0000000..ebea10e --- /dev/null +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.tsx @@ -0,0 +1,1174 @@ +import { AddRawMaterialOperationModel, BaseActivityModel, GenericAlertOptions, IrrigationOperationOptions } from "@models/FarmCalendarActivities"; +import { ActivityDynamicCRUDActionsProps } from "./ActivityDynamicCRUDActions.types"; +import { Box, Button, Card, CardContent, IconButton, List, ListItem, ListItemButton, ListItemText, Stack, TextField, Typography } from "@mui/material"; +import dayjs, { Dayjs } from "dayjs"; +import { useEffect, useState } from "react"; +import { DateTimePicker } from "@mui/x-date-pickers"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { FarmParcelModel } from "@models/FarmParcel"; +import { FarmCropModel } from "@models/FarmCrop"; +import { FarmAnimalModel } from "@models/FarmAnimalModel"; + +import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; +import DeleteIcon from '@mui/icons-material/Delete'; +import useDialog from "@hooks/useDialog"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import { AgriculturalMachine } from "@models/AgriculturalMachine"; +import { PesticideModel } from "@models/Pesticide"; +import { FertilizerModel } from "@models/Fertilizer"; + +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; +import useFetch from "@hooks/useFetch"; +import { useNavigate } from "react-router-dom"; + +const REQUIRED_KEYS_BY_TYPE: Record> = { + AddRawMaterialOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', 'hasCompostMaterial', + ]), + CompostOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'isOperatedOn', + ]), + CompostTurningOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + ]), + CropGrowthStageObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasAgriCrop', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + CropProtectionOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', 'usesPesticide', + ]), + CropStressIndicatorObservation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasAgriCrop', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + DiseaseDetection: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + FertilizationOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', + 'hasApplicationMethod', 'usesFertilizer', + ]), + FarmCalendarActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', + ]), + Alert: new Set([ + 'title', 'validFrom', 'validTo', 'relatedObservation', 'severity', + ]), + Observation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + IrrigationOperation: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'operatedOn', + 'hasAppliedAmount.numericValue', 'hasAppliedAmount.unit', 'usesIrrigationSystem', + ]), + SprayingRecommendation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', 'usesPesticide', + ]), + VigorEstimation: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + YieldPrediction: new Set([ + 'title', 'phenomenonTime', 'madeBySensor.name', 'hasArea', + 'hasResult.hasValue', 'hasResult.unit', 'observedProperty', + ]), + AnimalActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'hasAnimal', + ]), + AnimalLactatingActivity: new Set([ + 'title', 'responsibleAgent', 'hasStartDatetime', 'hasAnimal', + 'hasDaysInMilk', 'hasLactationNumber', 'hasControl', + 'hasTotalMilkYield.hasValue', 'hasTotalMilkYield.unit', + 'hasMilkYield.hasValue', 'hasMilkYield.unit', + 'hasRCS.hasValue', 'hasRCS.unit', + 'hasUrea.hasValue', 'hasUrea.unit', + 'hasFat.hasValue', 'hasFat.unit', + 'hasProtein.hasValue', 'hasProtein.unit', + 'hasDryMatter.hasValue', 'hasDryMatter.unit', + ]), +}; + +const ActivityDynamicCRUDActions = ({ activity, activityTypes, onAdd, onDelete, onSave, loading, canEdit, canDelete }: ActivityDynamicCRUDActionsProps) => { + const navigate = useNavigate(); + + const [formData, setFormData] = useState(activity); + const requiredKeys = REQUIRED_KEYS_BY_TYPE[formData['@type']] ?? new Set(); + const isReq = (key: string) => requiredKeys.has(key); + const [selectedParcel, setSelectedParcel] = useState(''); + const [selectedAgriCrop, setSelectedAgriCrop] = useState(''); + const [selectedAgriMachines, setSelectedAgriMachines] = useState([]); + const [operatedOnCompostPile, setOperatedOnCompostPile] = useState(''); + const [selectedPesticide, setSelectedPesticide] = useState(''); + const [selectedFertilizer, setSelectedFertilizer] = useState(''); + const [parentActivity, setParentActivity] = useState(''); + const [severity, setSeverity] = useState(''); + const [usesIrrigationSystem, setUsesIrrigationSystem] = useState(''); + const [selectedAnimal, setSelectedAnimal] = useState(''); + + /** All calendar activities section start */ + const [allActivities, setAllActivities] = useState([]); + const [allObservations, setAllObservations] = useState([]); + + const { fetchData: fetchDataAllActivities, response: responseAllActivities } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivities/?format=json`, + { + method: 'GET', + } + ); + + const { fetchData: fetchDataAllObservations, response: responseAllObservations } = useFetch( + `proxy/farmcalendar/api/v1/Observations/?format=json`, + { + method: 'GET', + } + ); + + useEffect(() => { + if (responseAllActivities) { + setAllActivities(responseAllActivities); + } + }, [responseAllActivities]) + + useEffect(() => { + if (responseAllObservations) { + setAllObservations(responseAllObservations); + } + }, [responseAllObservations]) + /** All calendar activities section end */ + + useEffect(() => { + fetchDataAllActivities(); + if ('relatedObservation' in formData) { + fetchDataAllObservations(); + } + + let parcelID: string | undefined; + if ('hasAgriParcel' in formData) { + if (formData.hasAgriParcel) + parcelID = (formData as any).hasAgriParcel["@id"]; + } else if ('operatedOn' in formData) { + if (formData.operatedOn) + parcelID = (formData as any).operatedOn["@id"]; + } + if (parcelID) { + const idParts = parcelID.split(':'); + setSelectedParcel(idParts[idParts.length - 1]); + } + + let parentActivityID: string | undefined; + if ('isPartOfActivity' in formData) { + if (formData.isPartOfActivity) + parentActivityID = (formData as any).isPartOfActivity["@id"]; + } else if ('relatedObservation' in formData) { + if (formData.relatedObservation) + parentActivityID = (formData as any).relatedObservation["@id"]; + } + if (parentActivityID) { + const idParts = parentActivityID.split(':'); + setParentActivity(idParts[idParts.length - 1]); + } + + let cropID: string | undefined; + if ('hasAgriCrop' in formData) { + if (formData.hasAgriCrop) + cropID = (formData as any).hasAgriCrop["@id"]; + } + if (cropID) { + const idParts = cropID.split(':'); + setSelectedAgriCrop(idParts[idParts.length - 1]); + } + + let animalID: string | undefined; + if ('hasAnimal' in formData) { + if ((formData as any).hasAnimal) + animalID = (formData as any).hasAnimal["@id"]; + } + if (animalID) { + const idParts = animalID.split(':'); + setSelectedAnimal(idParts[idParts.length - 1]); + } + + let agriMachinesIDs: string[] | undefined; + if ('usesAgriculturalMachinery' in formData) { + if (formData.usesAgriculturalMachinery) + agriMachinesIDs = (formData as any).usesAgriculturalMachinery.map((m: any) => { return m["@id"].split(':')[3] }); + } + if (agriMachinesIDs) { + setSelectedAgriMachines(agriMachinesIDs); + } + + let operatedOnCompostPile: string | undefined; + if ('isOperatedOn' in formData) { + if (formData.isOperatedOn) + operatedOnCompostPile = (formData as any).isOperatedOn["@id"]; + } + if (operatedOnCompostPile) { + const idParts = operatedOnCompostPile.split(':'); + setOperatedOnCompostPile(idParts[idParts.length - 1]); + } + + let pesticideID: string | undefined; + if ('usesPesticide' in formData) { + if (formData.usesPesticide) + pesticideID = (formData as any).usesPesticide["@id"]; + } + if (pesticideID) { + const idParts = pesticideID.split(':'); + setSelectedPesticide(idParts[idParts.length - 1]); + } + + let fertilizerID: string | undefined; + if ('usesFertilizer' in formData) { + if (formData.usesFertilizer) + fertilizerID = (formData as any).usesFertilizer["@id"]; + } + if (fertilizerID) { + const idParts = fertilizerID.split(':'); + setSelectedFertilizer(idParts[idParts.length - 1]); + } + + let severity: string | undefined; + if ('severity' in formData) { + if (formData.severity) + severity = (formData as any).severity; + } + if (severity) { + setSeverity(severity); + } + + let usesIrrigationSystem: string | undefined; + if ('usesIrrigationSystem' in formData) { + if (formData.usesIrrigationSystem) + usesIrrigationSystem = (formData as any).usesIrrigationSystem; + } + if (usesIrrigationSystem) { + setUsesIrrigationSystem(usesIrrigationSystem); + } + }, []); + + const { dialogProps, showDialog } = useDialog(); + + const handleCloseDialog = () => { + dialogProps.onClose(); + }; + + /** -------------------------------------------------------------------------- */ + + /** Field change handlers start */ + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + const newState = JSON.parse(JSON.stringify(prev)) as T; + + let currentLevel: any = newState; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + const finalKey = keys[keys.length - 1]; + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}":`, error); + return prev; + } + return newState; + }); + }; + + const handleDateChange = (newValue: Dayjs | null, fieldName: string) => { + const formattedValue = newValue ? newValue.toISOString() : null; + setFormData(prev => ({ ...prev, [fieldName]: formattedValue } as T)); + }; + + const handleOperatedOnCompostPile = (e: React.ChangeEvent) => { + setOperatedOnCompostPile(e.target.value); + } + + const handleAddCompostMaterial = () => { + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + const newMaterial = { + "@id": Date.now().toString(), + "@type": '', + typeName: '', + quantityValue: { + "@id": '', + "@type": '', + unit: '', + numericValue: 0 + } + }; + newState.hasCompostMaterial.push(newMaterial); + + return newState; + }); + }; + + const handleRemoveCompostMaterial = (itemId: string) => () => { + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + newState.hasCompostMaterial = newState.hasCompostMaterial.filter(mat => mat['@id'] !== itemId); + + return newState; + }); + }; + + const handleCompostMaterialChange = (itemId: string) => + (e: React.ChangeEvent) => { + + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + if (!prev || !('hasCompostMaterial' in prev)) return prev; + + const newState = JSON.parse(JSON.stringify(prev)) as T & AddRawMaterialOperationModel; + + const materialToUpdate = newState.hasCompostMaterial.find(mat => mat['@id'] === itemId); + if (!materialToUpdate) return prev; + + let currentLevel: any = materialToUpdate; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + + const finalKey = keys[keys.length - 1]; + + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}" on item ${itemId}:`, error); + return prev; + } + + return newState; + }); + }; + /** Field change handlers end */ + + /** -------------------------------------------------------------------------- */ + + const isDateInvalid = (v: any) => !(v && dayjs(v).isValid()); + + /** Field rendering helpers start */ + const renderDateFields = () => { + return ( + <> + {'hasStartDatetime' in formData && ( + handleDateChange(val, 'hasStartDatetime')} + slotProps={{ + textField: { + required: isReq('hasStartDatetime'), + error: isReq('hasStartDatetime') && isDateInvalid(formData.hasStartDatetime), + }, + }} + /> + )} + {'phenomenonTime' in formData && ( + handleDateChange(val, 'phenomenonTime')} + slotProps={{ + textField: { + required: isReq('phenomenonTime'), + error: isReq('phenomenonTime') && isDateInvalid(formData.phenomenonTime), + }, + }} + /> + )} + {'validFrom' in formData && ( + handleDateChange(val, 'validFrom')} + slotProps={{ + textField: { + required: isReq('validFrom'), + error: isReq('validFrom') && isDateInvalid(formData.validFrom), + }, + }} + /> + )} + {'hasEndDatetime' in formData && ( + handleDateChange(val, 'hasEndDatetime')} + /> + )} + {'validTo' in formData && ( + handleDateChange(val, 'validTo')} + slotProps={{ + textField: { + required: isReq('validTo'), + error: isReq('validTo') && isDateInvalid(formData.validTo), + }, + }} + /> + )} + + ) + } + + const renderAgentAndMachinery = () => { + return ( + <> + {'responsibleAgent' in formData && ( + + )} + {'usesAgriculturalMachinery' in formData && ( + + canEdit={canEdit} + multiple={true} + endpoint='proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json' + label='Agricultural machines' + getOptionLabel={item => `${item.name} (${item.model})`} + getOptionValue={item => item["@id"].split(':')[3]} + setSelectedValue={setSelectedAgriMachines} + selectedValue={selectedAgriMachines} + /> + )} + + ) + } + + const renderSensorResultAndObservedProperty = () => { + type SensorShape = { name: string }; + type ResultShape = { unit: string; hasValue: string }; + + return ( + <> + {'madeBySensor' in formData && ( + + )} + {'hasResult' in formData && ( + + + + + )} + {'observedProperty' in formData && ( + + )} + + ) + } + + const renderSeverity = () => { + return ( + <> + {'severity' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/Alerts/?format=json' + method="OPTIONS" + label='Severity' + selectedValue={severity} + setSelectedValue={setSeverity} + transformResponse={a => a.actions.POST.severity.choices} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + required={isReq('severity')} + error={isReq('severity') && !severity} + /> + )} + + ) + } + + const renderUsesIrrigationSystem = () => { + return ( + <> + {'usesIrrigationSystem' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/IrrigationOperations/?format=json' + method="OPTIONS" + label='Irrigation system' + selectedValue={usesIrrigationSystem} + setSelectedValue={setUsesIrrigationSystem} + transformResponse={a => a.actions.POST.usesIrrigationSystem.choices} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + required={isReq('usesIrrigationSystem')} + error={isReq('usesIrrigationSystem') && !usesIrrigationSystem} + /> + )} + + ) + } + + const renderSelectedCrop = () => { + // NTH: filter if parcel is selected + return ( + <> + {'hasAgriCrop' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmCrops/?format=json' + label='Crop' + selectedValue={selectedAgriCrop} + setSelectedValue={setSelectedAgriCrop} + getOptionLabel={item => `${item.name} - ${item.cropSpecies.name} - ${item.growth_stage}`} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('hasAgriCrop')} + error={isReq('hasAgriCrop') && !selectedAgriCrop} + /> + )} + + ) + } + + const renderSelectedAnimal = () => { + return ( + <> + {'hasAnimal' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmAnimals/?format=json' + label='Animal' + selectedValue={selectedAnimal} + setSelectedValue={setSelectedAnimal} + getOptionLabel={item => `${item.name || item.nationalID} - ${item.species} ${item.breed ? '(' + item.breed + ')' : ''}`} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('hasAnimal')} + error={isReq('hasAnimal') && !selectedAnimal} + /> + )} + + ) + } + + const renderLactationFields = () => { + if (!('hasDaysInMilk' in formData)) return null; + const measurements: { key: string; label: string }[] = [ + { key: 'hasTotalMilkYield', label: 'Total milk yield' }, + { key: 'hasMilkYield', label: 'Milk yield' }, + { key: 'hasRCS', label: 'RCS' }, + { key: 'hasUrea', label: 'Urea' }, + { key: 'hasFat', label: 'Fat' }, + { key: 'hasProtein', label: 'Protein' }, + { key: 'hasDryMatter', label: 'Dry matter' }, + ]; + return ( + <> + + + + + + {measurements.map(m => ( + + + + + ))} + + ) + } + + const renderHasArea = () => { + return ( + <> + {'hasArea' in formData && ( + + )} + + ) + } + + const renderOperatedOnCompostPile = () => { + return ( + <> + {'isOperatedOn' in formData && ( + + )} + + ) + } + + const renderApplicationMethod = () => { + return ( + <> + {'hasApplicationMethod' in formData && ( + + )} + + ) + } + + const renderAppliedAmount = () => { + type AppliedAmountShape = { unit: string, numericValue: number }; + + return ( + <> + {'hasAppliedAmount' in formData && ( + + + + + )} + + ) + } + + const renderPesticide = () => { + // NTH: filter if parcel is selected + return ( + <> + {'usesPesticide' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/Pesticides/?format=json' + label='Pesticide' + selectedValue={selectedPesticide} + setSelectedValue={setSelectedPesticide} + getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasPreharvestInterval}`} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('usesPesticide')} + error={isReq('usesPesticide') && !selectedPesticide} + /> + )} + + ) + } + + const renderFertilizer = () => { + // NTH: filter if parcel is selected + return ( + <> + {'usesFertilizer' in formData && ( + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/Fertilizers/?format=json' + label='Fertilizer' + selectedValue={selectedFertilizer} + setSelectedValue={setSelectedFertilizer} + getOptionLabel={item => `${item.hasCommercialName} - ${item.hasActiveSubstance} - ${item.hasNutrientConcentration}`} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('usesFertilizer')} + error={isReq('usesFertilizer') && !selectedFertilizer} + /> + )} + + ) + } + + const renderCompostMaterials = () => { + return ( + <> + {'hasCompostMaterial' in formData && ( + + Compost materials + {(formData.hasCompostMaterial as AddRawMaterialOperationModel['hasCompostMaterial']).map((compMat) => { + return ( + + + + + + + + + + + + + + + + + ); + })} + {canEdit && + + + + } + + )} + + ) + } + + const renderNestedActivities = () => { + let nestedActivities = []; + if ('hasMeasurement' in formData) { + nestedActivities.push(...(formData.hasMeasurement as any)); + } + if ('hasNestedOperation' in formData) { + nestedActivities.push(...(formData.hasNestedOperation as any)); + } + if (nestedActivities.length && allActivities.length) { + const allActivitiesWithEndpoints = allActivities.map(a => { + return { + ...a, + activity_endpoint: activityTypes.find(at => at["@id"].split(':')[3] === a.activityType["@id"].split(':')[3])?.activity_endpoint + } + }) + + nestedActivities = nestedActivities.map(na => { + const naID = na["@id"].split(':')[3]; + const additionalParams = allActivitiesWithEndpoints.find(a => a["@id"].split(':')[3] === naID); + + return { + ...na, + title: additionalParams?.title, + hasStartDatetime: additionalParams?.hasStartDatetime, + hasEndDatetime: additionalParams?.hasEndDatetime, + activity_endpoint: additionalParams?.activity_endpoint + } + }) + } + return ( + <> + {nestedActivities.length > 0 && + + + Nested activities + 10}> + {nestedActivities.map((n: any) => { + return + { + navigate(`/farm-calendar/edit-activity/${n["@id"].split(":")[3]}`, { state: { api: n.activity_endpoint, activityTypes: activityTypes } }) + + }}> + + + + })} + + + + } + + ) + } + /** Field rendering helpers end */ + + /** -------------------------------------------------------------------------- */ + + /** Validation start */ + const fieldValidators: Record boolean> = { + 'title': () => !!formData.title?.trim(), + 'responsibleAgent': () => !!((formData as any).responsibleAgent ?? '').toString().trim(), + 'hasStartDatetime': () => !isDateInvalid((formData as any).hasStartDatetime), + 'phenomenonTime': () => !isDateInvalid((formData as any).phenomenonTime), + 'validFrom': () => !isDateInvalid((formData as any).validFrom), + 'validTo': () => !isDateInvalid((formData as any).validTo), + 'operatedOn': () => !!selectedParcel, + 'hasAgriCrop': () => !!selectedAgriCrop, + 'usesPesticide': () => !!selectedPesticide, + 'usesFertilizer': () => !!selectedFertilizer, + 'usesIrrigationSystem': () => !!usesIrrigationSystem, + 'relatedObservation': () => !!parentActivity, + 'isOperatedOn': () => !!operatedOnCompostPile.trim(), + 'hasArea': () => !isNaN((formData as any).hasArea as number), + 'hasAppliedAmount.numericValue': () => !isNaN(((formData as any).hasAppliedAmount?.numericValue) as number), + 'hasAppliedAmount.unit': () => !!((formData as any).hasAppliedAmount?.unit ?? '').toString().trim(), + 'hasResult.hasValue': () => !!((formData as any).hasResult?.hasValue ?? '').toString().trim(), + 'hasResult.unit': () => !!((formData as any).hasResult?.unit ?? '').toString().trim(), + 'observedProperty': () => !!((formData as any).observedProperty ?? '').toString().trim(), + 'madeBySensor.name': () => !!((formData as any).madeBySensor?.name ?? '').toString().trim(), + 'hasApplicationMethod': () => !!((formData as any).hasApplicationMethod ?? '').toString().trim(), + 'severity': () => !!severity, + 'hasAnimal': () => !!selectedAnimal, + 'hasDaysInMilk': () => !!((formData as any).hasDaysInMilk ?? '').toString().trim(), + 'hasLactationNumber': () => !!((formData as any).hasLactationNumber ?? '').toString().trim(), + 'hasControl': () => !!((formData as any).hasControl ?? '').toString().trim(), + 'hasTotalMilkYield.hasValue': () => !!((formData as any).hasTotalMilkYield?.hasValue ?? '').toString().trim(), + 'hasTotalMilkYield.unit': () => !!((formData as any).hasTotalMilkYield?.unit ?? '').toString().trim(), + 'hasMilkYield.hasValue': () => !!((formData as any).hasMilkYield?.hasValue ?? '').toString().trim(), + 'hasMilkYield.unit': () => !!((formData as any).hasMilkYield?.unit ?? '').toString().trim(), + 'hasRCS.hasValue': () => !!((formData as any).hasRCS?.hasValue ?? '').toString().trim(), + 'hasRCS.unit': () => !!((formData as any).hasRCS?.unit ?? '').toString().trim(), + 'hasUrea.hasValue': () => !!((formData as any).hasUrea?.hasValue ?? '').toString().trim(), + 'hasUrea.unit': () => !!((formData as any).hasUrea?.unit ?? '').toString().trim(), + 'hasFat.hasValue': () => !!((formData as any).hasFat?.hasValue ?? '').toString().trim(), + 'hasFat.unit': () => !!((formData as any).hasFat?.unit ?? '').toString().trim(), + 'hasProtein.hasValue': () => !!((formData as any).hasProtein?.hasValue ?? '').toString().trim(), + 'hasProtein.unit': () => !!((formData as any).hasProtein?.unit ?? '').toString().trim(), + 'hasDryMatter.hasValue': () => !!((formData as any).hasDryMatter?.hasValue ?? '').toString().trim(), + 'hasDryMatter.unit': () => !!((formData as any).hasDryMatter?.unit ?? '').toString().trim(), + 'hasCompostMaterial': () => { + const arr = (formData as any).hasCompostMaterial; + if (!Array.isArray(arr) || arr.length === 0) return false; + return arr.every((m: any) => + !!m.typeName?.trim() + && !isNaN(m.quantityValue?.numericValue) + && !!m.quantityValue?.unit?.trim() + ); + }, + }; + + const isFormInvalid = !selectedParcel || Array.from(requiredKeys).some(k => { + const fn = fieldValidators[k]; + return fn ? !fn() : false; + }); + /** Validation end */ + + /** Button handler functions start */ + const prepPostAndPatch = () => { + let body = { ...formData }; + if ('hasAgriParcel' in body) { + (body.hasAgriParcel as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } else if ('operatedOn' in body) { + (body.operatedOn as { '@id': string })['@id'] = `urn:farmcalendar:Parcel:${selectedParcel}`; + } + if ('isPartOfActivity' in body) { + if (!body.isPartOfActivity) { + body.isPartOfActivity = { '@id': '', '@type': 'FarmCalendarActivity' } + } + if (parentActivity) { + (body.isPartOfActivity as { '@id': string })['@id'] = `urn:farmcalendar:FarmCalendarActivity:${parentActivity}`; + } else { + body.isPartOfActivity = null; + } + } else if ('relatedObservation' in body) { + if (!body.relatedObservation) { + body.relatedObservation = { '@id': '', '@type': 'Observation' } + } + if (parentActivity) { + (body.relatedObservation as { '@id': string })['@id'] = `urn:farmcalendar:Observation:${parentActivity}`; + } else { + body.relatedObservation = null; + } + } + if ('hasAgriCrop' in body) { + (body.hasAgriCrop as { '@id': string })['@id'] = `urn:farmcalendar:FarmCrop:${selectedAgriCrop}`; + } + if ('hasAnimal' in body) { + (body.hasAnimal as { '@id': string })['@id'] = `urn:farmcalendar:Animal:${selectedAnimal}`; + } + if ('usesAgriculturalMachinery' in body) { + (body.usesAgriculturalMachinery as string[]) = selectedAgriMachines; + } + if ('isOperatedOn' in body) { + (body.isOperatedOn as { '@id': string })['@id'] = `urn:farmcalendar:CompostPile:${operatedOnCompostPile}`; + } + if ('usesPesticide' in body) { + (body.usesPesticide as { '@id': string })['@id'] = `urn:farmcalendar:Pesticide:${selectedPesticide}`; + } + if ('usesFertilizer' in body) { + if (!body.usesFertilizer) { + body.usesFertilizer = { '@id': '', '@type': 'Fertilizer' }; + } + (body.usesFertilizer as { '@id': string })['@id'] = `urn:farmcalendar:Fertilizer:${selectedFertilizer}`; + } + if ('severity' in body) { + body.severity = severity; + } + // Necessary for generic alerts as they have this field set to null + if ('quantityValue' in body && !body.quantityValue) { + body.quantityValue = {}; + } + if ('usesIrrigationSystem' in body) { + body.usesIrrigationSystem = usesIrrigationSystem; + } + return body; + } + + const handlePost = () => { + onAdd && onAdd(prepPostAndPatch()); + }; + + const handlePatch = () => { + const body = prepPostAndPatch(); + + console.log(body); + + onSave && onSave(body); + } + + const handleDelete = () => { + onDelete && onDelete(); + }; + /** Button handler functions end */ + + return ( + <> + + + + + {renderDateFields()} + + {renderAgentAndMachinery()} + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmParcels/?format=json' + label='Parcel' + selectedValue={selectedParcel} + setSelectedValue={setSelectedParcel} + getOptionLabel={item => `${item.identifier} (${item.category})`} + getOptionValue={item => item["@id"].split(':')[3]} + required + error={!selectedParcel} + /> + {/* NTH: string filtering of the displayed activities */} + + canEdit={canEdit} + endpoint='' + data={'relatedObservation' in formData ? allObservations : allActivities} + label={'relatedObservation' in formData ? 'Related observation' : 'Part of activity'} + // Filtering out the current activity to avoid recursive links + transformResponse={resp => { + return resp.filter(fa => { + return fa["@id"].split(':')[3] !== formData["@id"].split(':')[3] + }) + }} + selectedValue={parentActivity} + setSelectedValue={setParentActivity} + getOptionLabel={item => { + const start = (item as any).hasStartDatetime ?? (item as any).phenomenonTime; + const end = (item as any).hasEndDatetime; + return `${item.title} (${dayjs(start).format('YYYY-MM-DD HH:mm')}${end ? ` - ${dayjs(end).format('YYYY-MM-DD HH:mm')}` : ''})`; + }} + getOptionValue={item => item["@id"].split(':')[3]} + required={isReq('relatedObservation')} + error={isReq('relatedObservation') && !parentActivity} + /> + {renderSeverity()} + {renderSensorResultAndObservedProperty()} + {renderSelectedCrop()} + {renderSelectedAnimal()} + {renderLactationFields()} + {renderHasArea()} + {renderOperatedOnCompostPile()} + {renderAppliedAmount()} + {renderUsesIrrigationSystem()} + {renderApplicationMethod()} + {renderPesticide()} + {renderFertilizer()} + {renderCompostMaterials()} + {renderNestedActivities()} + + + + + {onAdd && } + {onDelete && } + {onSave && } + + + + ) +} + +export default ActivityDynamicCRUDActions; \ No newline at end of file diff --git a/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts new file mode 100644 index 0000000..266af76 --- /dev/null +++ b/src/components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions.types.ts @@ -0,0 +1,13 @@ +import { BaseActivityModel } from "@models/FarmCalendarActivities"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; + +export interface ActivityDynamicCRUDActionsProps { + activity: T; + activityTypes: FarmCalendarActivityTypeModel[]; + onSave?: (activityToSave: BaseActivityModel) => void; + onAdd?: (activityToSave: BaseActivityModel) => void; + onDelete?: () => void; + loading: boolean; + canEdit: boolean; + canDelete: boolean; +} \ No newline at end of file diff --git a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx index a4bdb31..1180003 100644 --- a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx +++ b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.tsx @@ -28,7 +28,7 @@ const createEmptyPest = () => ({ ] }); -const PestCRUDActions: React.FC = ({ pest, onAction }) => { +const PestCRUDActions: React.FC = ({ pest, onAction, canEdit, canDelete }) => { const theme = useTheme(); const [formData, setFormData] = useState(); @@ -184,11 +184,11 @@ const PestCRUDActions: React.FC = ({ pest, onAction }) => <> - - + + - - + + @@ -204,24 +204,25 @@ const PestCRUDActions: React.FC = ({ pest, onAction }) => - handleGddPointChange(index, e)} disabled={index > 0 || formData.gdd_points.length > 1} error={isNaN(gddp.start) || gddp.start < 0} /> - handleGddPointChange(index, e)} disabled={!isLastPoint} error={isNaN(gddp.end) || isEndValueError} /> + handleGddPointChange(index, e)} disabled={index > 0 || formData.gdd_points.length > 1} error={isNaN(gddp.start) || gddp.start < 0} /> + handleGddPointChange(index, e)} disabled={!isLastPoint} error={isNaN(gddp.end) || isEndValueError} /> - handleGddPointChange(index, e)} sx={{ flexGrow: 1 }} error={!gddp.descriptor?.trim()} /> - {shouldShowRemoveButton && ()} + handleGddPointChange(index, e)} sx={{ flexGrow: 1 }} error={!gddp.descriptor?.trim()} /> + {shouldShowRemoveButton && ()} ); })} - - - - + {canEdit && + + + + } {pest && @@ -231,6 +232,7 @@ const PestCRUDActions: React.FC = ({ pest, onAction }) => startIcon={} loading={loading} loadingPosition="start" + disabled={!canDelete} onClick={() => { showDialog({ title: `Are you sure you want to delete this pest?`, @@ -247,7 +249,7 @@ const PestCRUDActions: React.FC = ({ pest, onAction }) => startIcon={} loading={loading} loadingPosition="start" - disabled={isFormInvalid} + disabled={isFormInvalid || !canEdit} onClick={handlePatch} > Save Changes diff --git a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.types.ts b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.types.ts index 0cb53f5..9c3d48a 100644 --- a/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.types.ts +++ b/src/components/dashboard/services/PestCRUDActions/PestCRUDActions.types.ts @@ -3,4 +3,6 @@ import { PestModel } from "@models/Pest"; export interface PestCRUDActionsProps { pest?: PestModel; onAction?: () => void + canEdit: boolean; + canDelete: boolean; } \ No newline at end of file diff --git a/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.tsx b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.tsx new file mode 100644 index 0000000..03296f3 --- /dev/null +++ b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.tsx @@ -0,0 +1,511 @@ +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Card, CardContent, Divider, FormControl, IconButton, InputLabel, MenuItem, Select, Stack, TextField, Typography, useTheme } from "@mui/material"; +import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; +import DeleteIcon from '@mui/icons-material/Delete'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useState, useEffect } from "react"; +import { ThreatModelCRUDActionsProps } from "./ThreatModelCRUDActions.types"; +import { BioParams, FuzzyRule, RISK_LEVELS, RiskLevel, ThreatModel, ThreatModelCreate, ThreatModelUpdate } from "@models/ThreatModel"; +import useFetch from "@hooks/useFetch"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useSnackbar from "@hooks/useSnackbar"; +import useDialog from "@hooks/useDialog"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; + +interface FormState { + scientific_name: string; + common_name: string; + label: string; + note: string; + crop_id: string; + bio_params: BioParams; + fuzzy_rules: FuzzyRule[]; +} + +const BIO_PARAM_FIELDS: { key: keyof BioParams; label: string }[] = [ + { key: 't_base', label: 'T base' }, + { key: 't_lethal_min', label: 'T lethal min' }, + { key: 't_lethal_max', label: 'T lethal max' }, + { key: 't_optimal_min', label: 'T optimal min' }, + { key: 't_optimal_max', label: 'T optimal max' }, + { key: 'min_streak', label: 'Min streak' }, + { key: 'pheno_frac_lo', label: 'Pheno frac lo' }, + { key: 'pheno_frac_hi', label: 'Pheno frac hi' }, + { key: 'pheno_frac_ref_gdd5', label: 'Pheno frac ref GDD5' }, + { key: 'pheno_lo', label: 'Pheno lo' }, + { key: 'pheno_hi', label: 'Pheno hi' }, + { key: 'min_wetness_hours_critical', label: 'Min wetness hours critical' }, + { key: 'min_wetness_hours_high', label: 'Min wetness hours high' }, +]; + +const createEmptyRule = (): FuzzyRule => ({ + hum_lo: 0, + hum_hi: 100, + temp_lo: -999, + temp_hi: 999, + rain_min: 0, + risk_level: 'low', + type: '', +}); + +const createEmptyForm = (): FormState => ({ + scientific_name: '', + common_name: '', + label: '', + note: '', + crop_id: '', + bio_params: {}, + fuzzy_rules: [createEmptyRule()], +}); + +const fromThreatModel = (tm: ThreatModel): FormState => ({ + scientific_name: tm.scientific_name ?? '', + common_name: tm.common_name ?? '', + label: tm.label ?? '', + note: tm.note ?? '', + crop_id: tm.crop_id ?? '', + bio_params: { ...(tm.definition?.bio_params ?? {}) }, + fuzzy_rules: Array.isArray(tm.definition?.fuzzy_rules) && tm.definition.fuzzy_rules.length > 0 + ? tm.definition.fuzzy_rules.map(r => ({ ...r })) + : [createEmptyRule()], +}); + +const parseOptionalNumber = (raw: string): number | null => { + if (raw === '' || raw === '-') return null; + const v = Number(raw); + return Number.isNaN(v) ? null : v; +}; + +const sanitizeBioParams = (bp: BioParams): BioParams => { + const out: BioParams = {}; + (Object.keys(bp) as (keyof BioParams)[]).forEach(key => { + const value = bp[key]; + if (value !== undefined && value !== null && !Number.isNaN(value)) { + out[key] = value; + } + }); + return out; +}; + +const buildCreatePayload = (form: FormState): ThreatModelCreate => ({ + scientific_name: form.scientific_name.trim(), + common_name: form.common_name.trim(), + label: form.label.trim() === '' ? null : form.label.trim(), + note: form.note.trim() === '' ? null : form.note.trim(), + crop_id: form.crop_id, + definition: { + bio_params: sanitizeBioParams(form.bio_params), + fuzzy_rules: form.fuzzy_rules.map(r => ({ + hum_lo: r.hum_lo ?? 0, + hum_hi: r.hum_hi ?? 100, + temp_lo: r.temp_lo ?? -999, + temp_hi: r.temp_hi ?? 999, + rain_min: r.rain_min ?? 0, + risk_level: r.risk_level, + type: r.type && r.type.trim() !== '' ? r.type.trim() : null, + })), + }, +}); + +const buildUpdatePayload = (form: FormState): ThreatModelUpdate => ({ + scientific_name: form.scientific_name.trim(), + common_name: form.common_name.trim(), + label: form.label.trim() === '' ? null : form.label.trim(), + note: form.note.trim() === '' ? null : form.note.trim(), + definition: { + bio_params: sanitizeBioParams(form.bio_params), + fuzzy_rules: form.fuzzy_rules.map(r => ({ + hum_lo: r.hum_lo ?? 0, + hum_hi: r.hum_hi ?? 100, + temp_lo: r.temp_lo ?? -999, + temp_hi: r.temp_hi ?? 999, + rain_min: r.rain_min ?? 0, + risk_level: r.risk_level, + type: r.type && r.type.trim() !== '' ? r.type.trim() : null, + })), + }, +}); + +const ThreatModelCRUDActions: React.FC = ({ threatModel, crops, onAction, canEdit, canDelete }) => { + const theme = useTheme(); + + const [formData, setFormData] = useState(createEmptyForm); + + useEffect(() => { + setFormData(threatModel ? fromThreatModel(threatModel) : createEmptyForm()); + }, [threatModel]); + + const { dialogProps, showDialog } = useDialog(); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + const { fetchData: postFetch, response: postResponse, error: postError, loading: postLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/`, + { method: 'POST' } + ); + + const { fetchData: patchFetch, response: patchResponse, error: patchError, loading: patchLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/${threatModel?.id}/`, + { method: 'PATCH' } + ); + + const { fetchData: deleteFetch, response: deleteResponse, error: deleteError, loading: deleteLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/${threatModel?.id}/`, + { method: 'DELETE' } + ); + + useEffect(() => { + if (postResponse) { + onAction?.(); + showSnackbar('success', 'Threat model added successfully'); + } + }, [postResponse]); + + useEffect(() => { + if (patchResponse) { + onAction?.(); + showSnackbar('success', 'Threat model updated successfully'); + } + }, [patchResponse]); + + useEffect(() => { + if (deleteResponse) { + onAction?.(); + showSnackbar('success', 'Threat model deleted successfully'); + } + }, [deleteResponse]); + + useEffect(() => { + if (postError) showSnackbar('error', postError.message || 'Error creating threat model'); + }, [postError]); + + useEffect(() => { + if (patchError) showSnackbar('error', patchError.message || 'Error updating threat model'); + }, [patchError]); + + useEffect(() => { + if (deleteError) showSnackbar('error', deleteError.message || 'Error deleting threat model'); + }, [deleteError]); + + const handleTextChange = (field: keyof Pick) => + (e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, [field]: e.target.value })); + }; + + const handleCropChange = (cropId: string) => { + setFormData(prev => ({ ...prev, crop_id: cropId })); + }; + + const handleBioParamChange = (key: keyof BioParams, raw: string) => { + setFormData(prev => ({ + ...prev, + bio_params: { ...prev.bio_params, [key]: parseOptionalNumber(raw) }, + })); + }; + + const handleRuleNumberChange = (idx: number, key: 'hum_lo' | 'hum_hi' | 'temp_lo' | 'temp_hi' | 'rain_min', raw: string) => { + const parsed = parseOptionalNumber(raw); + setFormData(prev => ({ + ...prev, + fuzzy_rules: prev.fuzzy_rules.map((r, i) => + i === idx ? { ...r, [key]: parsed === null ? undefined : parsed } : r + ), + })); + }; + + const handleRuleRiskLevelChange = (idx: number, level: RiskLevel) => { + setFormData(prev => ({ + ...prev, + fuzzy_rules: prev.fuzzy_rules.map((r, i) => i === idx ? { ...r, risk_level: level } : r), + })); + }; + + const handleRuleTypeChange = (idx: number, value: string) => { + setFormData(prev => ({ + ...prev, + fuzzy_rules: prev.fuzzy_rules.map((r, i) => i === idx ? { ...r, type: value } : r), + })); + }; + + const handleAddRule = () => { + setFormData(prev => ({ ...prev, fuzzy_rules: [...prev.fuzzy_rules, createEmptyRule()] })); + }; + + const handleRemoveRule = (idx: number) => { + setFormData(prev => prev.fuzzy_rules.length <= 1 + ? prev + : { ...prev, fuzzy_rules: prev.fuzzy_rules.filter((_, i) => i !== idx) } + ); + }; + + const handlePost = () => { + postFetch({ body: buildCreatePayload(formData) }); + }; + + const handlePatch = () => { + patchFetch({ body: buildUpdatePayload(formData) }); + }; + + const handleDelete = () => { + deleteFetch(); + }; + + const isFormInvalid = + !formData.scientific_name.trim() || + !formData.common_name.trim() || + !formData.crop_id || + formData.fuzzy_rules.length < 1 || + formData.fuzzy_rules.some(r => !RISK_LEVELS.includes(r.risk_level)); + + const loading = postLoading || patchLoading || deleteLoading; + + return ( + <> + + + + + + + + + + Crop + + + + + + + + }> + Bio parameters + + + + {BIO_PARAM_FIELDS.map(({ key, label }) => { + const value = formData.bio_params[key]; + return ( + handleBioParamChange(key, e.target.value)} + /> + ); + })} + + + + + Fuzzy rules + {formData.fuzzy_rules.map((rule, idx) => { + const shouldShowRemoveButton = formData.fuzzy_rules.length > 1; + const humError = (rule.hum_lo ?? 0) < 0 || (rule.hum_hi ?? 100) > 100 || (rule.hum_lo ?? 0) > (rule.hum_hi ?? 100); + const tempError = (rule.temp_lo ?? -999) > (rule.temp_hi ?? 999); + const rainError = (rule.rain_min ?? 0) < 0; + return ( + + + + + #{idx + 1} + + Risk level + + + handleRuleTypeChange(idx, e.target.value)} + /> + {shouldShowRemoveButton && ( + handleRemoveRule(idx)}> + + + )} + + + handleRuleNumberChange(idx, 'hum_lo', e.target.value)} + error={humError} + inputProps={{ min: 0, max: 100 }} + /> + handleRuleNumberChange(idx, 'hum_hi', e.target.value)} + error={humError} + inputProps={{ min: 0, max: 100 }} + /> + handleRuleNumberChange(idx, 'temp_lo', e.target.value)} + error={tempError} + /> + handleRuleNumberChange(idx, 'temp_hi', e.target.value)} + error={tempError} + /> + handleRuleNumberChange(idx, 'rain_min', e.target.value)} + error={rainError} + inputProps={{ min: 0 }} + /> + + + + + ); + })} + {canEdit && ( + + + + )} + + + + {threatModel && ( + + + + + )} + {!threatModel && ( + + + + )} + + + + + ); +}; + +export default ThreatModelCRUDActions; diff --git a/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts new file mode 100644 index 0000000..624a3c7 --- /dev/null +++ b/src/components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions.types.ts @@ -0,0 +1,10 @@ +import { Crop } from "@models/Crop"; +import { ThreatModel } from "@models/ThreatModel"; + +export interface ThreatModelCRUDActionsProps { + threatModel?: ThreatModel; + crops: Crop[]; + onAction?: () => void; + canEdit: boolean; + canDelete: boolean; +} diff --git a/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx b/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx index fff6ebe..622f83d 100644 --- a/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx +++ b/src/components/dashboard/services/WeatherData/UAVFlightsForecast.tsx @@ -9,6 +9,7 @@ import dayjs from "dayjs"; import { Fragment, useEffect, useState } from "react"; import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import { AgriculturalMachine } from "@models/AgriculturalMachine"; interface ModelData { status?: UAVStatus @@ -58,7 +59,7 @@ const UAVFlightsForecast = () => { } ); - const { fetchData: agriMachineFetchData, loading: agriMachinesLoading, response: agriMachinesResponse, error: agriMachinesError } = useFetch( + const { fetchData: agriMachineFetchData, loading: agriMachinesLoading, response: agriMachinesResponse, error: agriMachinesError } = useFetch( `proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json`, { method: 'GET', diff --git a/src/components/shared/GenericSelect/GenericSelect.tsx b/src/components/shared/GenericSelect/GenericSelect.tsx index 808cf72..ca199e7 100644 --- a/src/components/shared/GenericSelect/GenericSelect.tsx +++ b/src/components/shared/GenericSelect/GenericSelect.tsx @@ -1,53 +1,114 @@ -import { Box, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent } from '@mui/material'; -import { useEffect } from 'react'; +import { Box, FormControl, FormHelperText, InputLabel, MenuItem, Select, SelectChangeEvent, Chip } from '@mui/material'; +import { useEffect, useMemo } from 'react'; import { GenericSelectProps } from './GenericSelect.types'; import useFetch from '@hooks/useFetch'; const GenericSelect = ({ endpoint, label, + method = "GET", getOptionLabel, getOptionValue, selectedValue, setSelectedValue, transformResponse, + canEdit = true, + data = undefined, + multiple = false, + error = false, + required = false, + helperText, }: GenericSelectProps) => { - const { fetchData, response, loading } = useFetch(endpoint, { method: 'GET' }); + const { fetchData, response, loading } = useFetch(endpoint, { method: method }); - const handleChange = (event: SelectChangeEvent) => { - setSelectedValue(event.target.value as string); + const handleChange = (event: SelectChangeEvent) => { + const { target: { value } } = event; + setSelectedValue(value as any); }; useEffect(() => { - fetchData(); + if (!data) fetchData(); }, []); - const items = response && transformResponse ? transformResponse(response) : response || []; + const items = useMemo(() => { + const dataToUse = data ? data : response; + return dataToUse && transformResponse ? transformResponse(dataToUse) : (dataToUse || []) as T[] + }, [data, response, transformResponse]); - const isValueValid = Array.isArray(items) && items.some(item => getOptionValue(item) === selectedValue); - const valueToRender = loading || !isValueValid ? '' : selectedValue; + const valueToRender = useMemo(() => { + if (loading) { + return multiple ? [] : ''; + } + + if (multiple) { + const selected = selectedValue as string[] | undefined ?? []; + if (items.length === 0 && selected.length > 0) return []; + const validValues = selected.filter(val => + items.some(item => getOptionValue(item) === val) + ); + return validValues; + } else { + const selected = selectedValue as string | undefined ?? ''; + if (items.length === 0 && selected) return ''; + const isValid = items.some(item => getOptionValue(item) === selected); + return isValid ? selected : ''; + } + }, [loading, multiple, selectedValue, items, getOptionValue]); return ( - + {label} + {helperText && {helperText}} ); diff --git a/src/components/shared/GenericSelect/GenericSelect.types.ts b/src/components/shared/GenericSelect/GenericSelect.types.ts index 98b6d3a..21534d8 100644 --- a/src/components/shared/GenericSelect/GenericSelect.types.ts +++ b/src/components/shared/GenericSelect/GenericSelect.types.ts @@ -1,9 +1,31 @@ -export interface GenericSelectProps { +interface GenericSelectPropsBase { endpoint: string; + data?: R; label: string; - getOptionLabel: (item: T) => string; // Function to get the display label for each item - getOptionValue: (item: T) => string | number; // Function to get the value for each item + method?: "GET" | "OPTIONS"; + canEdit?: boolean; + getOptionLabel: (item: T) => string; + getOptionValue: (item: T) => string; + transformResponse?: (response: R) => T[]; + error?: boolean; + required?: boolean; + helperText?: string; +} + +// Props for a SINGLE select (multiple is false or undefined) +type GenericSelectPropsSingle = GenericSelectPropsBase & { + multiple?: false; selectedValue: string | undefined; setSelectedValue: React.Dispatch>; - transformResponse?: (response: R) => T[]; // Function to transform the API response -} \ No newline at end of file +}; + +// Props for a MULTI select (multiple is true) +type GenericSelectPropsMultiple = GenericSelectPropsBase & { + multiple: true; + selectedValue: string[] | undefined; + setSelectedValue: React.Dispatch>; +}; + +export type GenericSelectProps = + | GenericSelectPropsSingle + | GenericSelectPropsMultiple; \ No newline at end of file diff --git a/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts b/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts index 0114fc8..d8b0d0c 100644 --- a/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts +++ b/src/components/shared/GenericSnackbar/GenericSnackbar.types.ts @@ -1,4 +1,4 @@ -type SnackbarType = 'success' | 'error' | 'info'; +type SnackbarType = 'success' | 'error' | 'info' | 'warning'; export interface GenericSnackbarProps { type: SnackbarType; diff --git a/src/components/shared/GenericSortableTable/GenericSortableTable.tsx b/src/components/shared/GenericSortableTable/GenericSortableTable.tsx index 2a8c25f..118ed9b 100644 --- a/src/components/shared/GenericSortableTable/GenericSortableTable.tsx +++ b/src/components/shared/GenericSortableTable/GenericSortableTable.tsx @@ -48,18 +48,22 @@ function EnhancedTableHead(props: EnhancedTableHeadProps) { padding={'normal'} sortDirection={orderBy === headCell.id ? order : false} > - - {headCell.label} - {orderBy === headCell.id ? ( - - {order === 'desc' ? 'sorted descending' : 'sorted ascending'} - - ) : null} - + {headCell.disableSort ? ( + headCell.label + ) : ( + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + )} ))} @@ -133,8 +137,8 @@ function GenericSortableTable({ onClick={() => onRowClick && onRowClick(row)} > {headCells.map((cell, cellIndex) => { - const originalValue = row[cell.id] as React.ReactNode; - const cellValue = originalValue == null ? 'N/A' : originalValue; + const rendered = cell.renderCell ? cell.renderCell(row) : (row[cell.id] as React.ReactNode); + const cellValue = rendered == null ? 'N/A' : rendered; if (cellIndex === 0) { return ( { id: keyof T; label: string; numeric: boolean; + disableSort?: boolean; + renderCell?: (row: T) => React.ReactNode; } export interface EnhancedTableHeadProps { diff --git a/src/components/shared/OfflineChip/OfflineChip.tsx b/src/components/shared/OfflineChip/OfflineChip.tsx new file mode 100644 index 0000000..5d5d345 --- /dev/null +++ b/src/components/shared/OfflineChip/OfflineChip.tsx @@ -0,0 +1,34 @@ +import { Chip, Tooltip } from "@mui/material"; +import CloudOffIcon from '@mui/icons-material/CloudOff'; +import { useEffect, useState } from "react"; + +const OfflineChip = () => { + const [offline, setOffline] = useState(!navigator.onLine); + + useEffect(() => { + const handleOnline = () => setOffline(false); + const handleOffline = () => setOffline(true); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + if (!offline) return null; + + return ( + + } + label="Offline" + color="warning" + size="medium" + variant="filled" + /> + + ); +}; + +export default OfflineChip; diff --git a/src/components/shared/WKTPolygonMap/WKTPolygonMap.tsx b/src/components/shared/WKTPolygonMap/WKTPolygonMap.tsx new file mode 100644 index 0000000..6c64567 --- /dev/null +++ b/src/components/shared/WKTPolygonMap/WKTPolygonMap.tsx @@ -0,0 +1,238 @@ +import 'ol/ol.css'; + +import { useEffect, useRef, useState } from 'react'; +import { Box, Button, Stack, TextField } from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; + +import Map from 'ol/Map'; +import View from 'ol/View'; +import Feature from 'ol/Feature'; +import TileLayer from 'ol/layer/Tile'; +import VectorLayer from 'ol/layer/Vector'; +import OSM from 'ol/source/OSM'; +import VectorSource from 'ol/source/Vector'; +import { Draw, Modify, Snap } from 'ol/interaction'; +import WKT from 'ol/format/WKT'; +import { fromLonLat } from 'ol/proj'; + +import { WKTPolygonMapProps } from './WKTPolygonMap.types'; + +const wktFormat = new WKT(); +const DATA_PROJ = 'EPSG:4326'; +const FEATURE_PROJ = 'EPSG:3857'; +const DEFAULT_CENTER: [number, number] = [15, 45]; +const DEFAULT_ZOOM = 5; + +const readWKTFeature = (wkt: string): Feature | null => { + if (!wkt?.trim()) return null; + try { + const feature = wktFormat.readFeature(wkt, { + dataProjection: DATA_PROJ, + featureProjection: FEATURE_PROJ, + }) as Feature; + const geom = feature.getGeometry(); + if (!geom || geom.getType() !== 'Polygon') return null; + return feature; + } catch { + return null; + } +}; + +const writeWKTFromFeature = (feature: Feature): string => + wktFormat.writeFeature(feature, { + dataProjection: DATA_PROJ, + featureProjection: FEATURE_PROJ, + decimals: 8, + }); + +const WKTPolygonMap: React.FC = ({ + value, + onChange, + readOnly = false, + center, + height = 400, +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const sourceRef = useRef(null); + const lastSourceWKTRef = useRef(''); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const [inputValue, setInputValue] = useState(value ?? ''); + const [inputError, setInputError] = useState(false); + const inputFocusedRef = useRef(false); + + useEffect(() => { + if (inputFocusedRef.current) return; + setInputValue(value ?? ''); + setInputError(false); + }, [value]); + + const handleInputChange = (e: React.ChangeEvent) => { + const next = e.target.value; + setInputValue(next); + if (!next.trim()) { + setInputError(false); + onChange(''); + return; + } + const feature = readWKTFeature(next); + if (!feature) { + setInputError(true); + return; + } + setInputError(false); + const canonical = writeWKTFromFeature(feature); + onChange(canonical); + }; + + useEffect(() => { + if (!containerRef.current) return; + + const source = new VectorSource(); + sourceRef.current = source; + + const hasCenter = center && center.lat != null && center.long != null; + const view = new View({ + center: fromLonLat( + hasCenter ? [center!.long as number, center!.lat as number] : DEFAULT_CENTER + ), + zoom: hasCenter ? 14 : DEFAULT_ZOOM, + }); + + const map = new Map({ + target: containerRef.current, + layers: [ + new TileLayer({ source: new OSM() }), + new VectorLayer({ source }), + ], + view, + }); + mapRef.current = map; + + const ro = new ResizeObserver(() => map.updateSize()); + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + map.setTarget(undefined); + mapRef.current = null; + sourceRef.current = null; + }; + }, []); + + useEffect(() => { + const source = sourceRef.current; + const map = mapRef.current; + if (!source || !map) return; + if (value === lastSourceWKTRef.current) return; + + source.clear(); + const feature = readWKTFeature(value); + if (feature) { + source.addFeature(feature); + const ext = feature.getGeometry()?.getExtent(); + if (ext) { + map.getView().fit(ext, { padding: [40, 40, 40, 40], maxZoom: 18 }); + } + } + lastSourceWKTRef.current = value ?? ''; + }, [value]); + + useEffect(() => { + const map = mapRef.current; + const source = sourceRef.current; + if (!map || !source || readOnly) return; + + const modify = new Modify({ source }); + const draw = new Draw({ source, type: 'Polygon' }); + const snap = new Snap({ source }); + + draw.on('drawstart', () => { + source.clear(); + }); + + draw.on('drawend', (e) => { + const wkt = writeWKTFromFeature(e.feature as Feature); + lastSourceWKTRef.current = wkt; + onChangeRef.current(wkt); + }); + + modify.on('modifyend', () => { + const features = source.getFeatures(); + if (!features.length) return; + const wkt = writeWKTFromFeature(features[0]); + lastSourceWKTRef.current = wkt; + onChangeRef.current(wkt); + }); + + map.addInteraction(modify); + map.addInteraction(draw); + map.addInteraction(snap); + + return () => { + map.removeInteraction(modify); + map.removeInteraction(draw); + map.removeInteraction(snap); + }; + }, [readOnly]); + + const handleClear = () => { + sourceRef.current?.clear(); + lastSourceWKTRef.current = ''; + onChange(''); + }; + + return ( + + + {!readOnly && ( + + + + )} + { inputFocusedRef.current = true; }} + onBlur={() => { + inputFocusedRef.current = false; + if (inputError || !inputValue.trim()) { + setInputValue(value ?? ''); + setInputError(false); + } + }} + fullWidth + multiline + minRows={2} + maxRows={4} + slotProps={{ input: { readOnly: readOnly } }} + placeholder="Draw a polygon on the map or paste WKT here" + size="small" + error={inputError} + helperText={inputError ? 'Invalid WKT polygon' : ' '} + /> + + ); +}; + +export default WKTPolygonMap; diff --git a/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts b/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts new file mode 100644 index 0000000..4918513 --- /dev/null +++ b/src/components/shared/WKTPolygonMap/WKTPolygonMap.types.ts @@ -0,0 +1,7 @@ +export interface WKTPolygonMapProps { + value: string; + onChange: (wkt: string) => void; + readOnly?: boolean; + center?: { lat: number | null; long: number | null }; + height?: number; +} diff --git a/src/contexts/SessionContext.ts b/src/contexts/SessionContext.ts index 269de60..4526c7a 100644 --- a/src/contexts/SessionContext.ts +++ b/src/contexts/SessionContext.ts @@ -1,12 +1,17 @@ import { FarmParcelModel } from "@models/FarmParcel"; import { createContext, useContext } from "react"; +export type ServiceActions = 'add' | 'delete' | 'edit' | 'view'; export interface Session { user: { - token?: string - refresh_token?: string + token?: string; + refresh_token?: string; }; farm_parcel?: FarmParcelModel; + services?: { + code: string; + actions: ServiceActions[]; + }[] } interface SessionContextType { diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts index d007aca..dfe82da 100644 --- a/src/hooks/useFetch.ts +++ b/src/hooks/useFetch.ts @@ -2,10 +2,12 @@ import { useSession } from "@contexts/SessionContext"; import { useState } from "react"; interface FetchOptions { - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; headers?: Record; body?: any; responseType?: 'json' | 'blob'; + url?: string; + noCache?: boolean; } const useFetch = ( @@ -50,16 +52,18 @@ const useFetch = ( finalHeaders["Authorization"] = `Bearer ${token}`; } - return { + const opts: RequestInit = { method: currentOptions.method, headers: finalHeaders, body: finalBody, }; + if (currentOptions.noCache) opts.cache = 'no-store'; + return opts; }; const initialToken = session?.user?.token; let fetchOptions = getFetchOptions(initialToken); - let response = await fetch(apiUrl + url, fetchOptions); + let response = await fetch(apiUrl + (currentOptions.url ?? url), fetchOptions); if (response.status === 401) { const refreshToken = session?.user?.refresh_token; @@ -93,7 +97,7 @@ const useFetch = ( }); fetchOptions = getFetchOptions(newToken.access); - response = await fetch(apiUrl + url, fetchOptions); + response = await fetch(apiUrl + (currentOptions.url ?? url), fetchOptions); } if (!response.ok) { diff --git a/src/hooks/useSnackbar.ts b/src/hooks/useSnackbar.ts index ca5056f..718c559 100644 --- a/src/hooks/useSnackbar.ts +++ b/src/hooks/useSnackbar.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -type SnackbarType = 'success' | 'error' | 'info'; +type SnackbarType = 'success' | 'error' | 'info' | 'warning'; interface SnackbarState { type: SnackbarType; @@ -16,6 +16,14 @@ const useSnackbar = () => { }); const showSnackbar = (type: SnackbarType, message: string) => { + if (type === 'error' && !navigator.onLine) { + setSnackbarState({ + open: true, + type: 'warning', + message: 'Failed to fetch — no internet connection. Showing cached data where available.', + }); + return; + } setSnackbarState({ open: true, type, message }); }; diff --git a/src/layouts/dashboard.tsx b/src/layouts/dashboard.tsx index 8335a98..919101d 100644 --- a/src/layouts/dashboard.tsx +++ b/src/layouts/dashboard.tsx @@ -1,12 +1,15 @@ -import { Outlet, useLocation } from 'react-router'; -import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import { Outlet, useLocation, useNavigate } from 'react-router'; +import { DashboardLayout, DashboardSidebarPageItem } from '@toolpad/core/DashboardLayout'; +import type { NavigationPageItem } from '@toolpad/core/AppProvider'; import { Breadcrumb, PageContainer } from '@toolpad/core/PageContainer'; import ToolbarActions from '@components/dashboard/ToolbarActions/ToolbarActions'; import { useSession } from '@contexts/SessionContext'; import Redirect from '@components/shared/Redirect/Redirect'; import { jwtDecode } from 'jwt-decode'; import Footer from '@components/shared/Footer'; -import { useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import useFetch from '@hooks/useFetch'; +import { clearUserCaches } from '@utils/pwaCache'; export type DashboardContextType = { setPageTitle: (title: string | undefined) => void; @@ -14,9 +17,60 @@ export type DashboardContextType = { }; export default function DashLayout() { - const { session } = useSession() + const { session, setSession } = useSession(); + const navigate = useNavigate(); const location = useLocation(); + const [pageTitle, setPageTitle] = useState(undefined); + const [breadcrumbs, setBreadcrumbs] = useState(undefined); + + const { fetchData, response, error } = useFetch( + "me/", + { + method: 'GET', + } + ); + + useEffect(() => { + if (session) { + fetchData(); + } + }, []); + + useEffect(() => { + if (session && response) { + setSession(prevSession => { + if (prevSession) { + return { + ...prevSession, + services: response.services + }; + } + return null; + }); + } + }, [response]) + + useEffect(() => { + if (error) { + if (!navigator.onLine) return; + clearUserCaches().finally(() => { + setSession(null); + navigate("/"); + }); + } + }, [error]) + + const renderPageItem = useCallback( + (item: NavigationPageItem) => { + if ((item as any).disabled) { + return ; + } + return ; + }, + [], + ); + const callbackURL = `?callbackURL=${encodeURIComponent(location.pathname)}`; @@ -27,9 +81,10 @@ export default function DashLayout() { // Decode the token to get its payload const decodedToken = jwtDecode(session.user.token); - // Check if the token has expired if ((decodedToken.exp ?? 0) < Date.now() / 1000) { - return ; + if (navigator.onLine) { + return ; + } } } catch (error) { // Token is invalid; redirect to sign-in @@ -37,11 +92,9 @@ export default function DashLayout() { } } - const [pageTitle, setPageTitle] = useState(undefined); - const [breadcrumbs, setBreadcrumbs] = useState(undefined); - return ( (); + + const service = session?.services?.find(s => s.code === 'FC'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/IrrigationManagementLayout.tsx b/src/layouts/services/IrrigationManagementLayout.tsx new file mode 100644 index 0000000..90d7d15 --- /dev/null +++ b/src/layouts/services/IrrigationManagementLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function IrrigationManagementLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'IRM'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/PestAndDiseaseLayout.tsx b/src/layouts/services/PestAndDiseaseLayout.tsx new file mode 100644 index 0000000..1f8e40a --- /dev/null +++ b/src/layouts/services/PestAndDiseaseLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function PestAndDiseaseLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'PDM'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/ReportingLayout.tsx b/src/layouts/services/ReportingLayout.tsx new file mode 100644 index 0000000..ce6a075 --- /dev/null +++ b/src/layouts/services/ReportingLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function ReportingLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'RP'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/layouts/services/WeatherDataLayout.tsx b/src/layouts/services/WeatherDataLayout.tsx new file mode 100644 index 0000000..64107e5 --- /dev/null +++ b/src/layouts/services/WeatherDataLayout.tsx @@ -0,0 +1,17 @@ +import { Outlet, useOutletContext } from 'react-router'; +import { useSession, ServiceActions } from '@contexts/SessionContext'; +import { DashboardContextType } from '@layouts/dashboard'; + +export type ServiceContextType = DashboardContextType & { + actions: ServiceActions[]; +}; + +export default function WeatherDataLayout() { + const { session } = useSession(); + const context = useOutletContext(); + + const service = session?.services?.find(s => s.code === 'WD'); + const actions = service?.actions || []; + + return ; +} diff --git a/src/main.tsx b/src/main.tsx index 1ae679d..4d4f1ed 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,7 +16,6 @@ import GrowingDegreeDaysPage from '@pages/dashboard/services/PestAndDisease/Grow import CompostOperationsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/CompostOperations.tsx'; import AuthLayout from '@layouts/auth.tsx'; import SignInPage from '@pages/auth/SignIn/SignInPage.tsx'; -import SignUpPage from '@pages/auth/SignUp/SignUpPage.tsx'; import FarmParcelPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx'; import FarmParcelsPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx'; import FarmsPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx'; @@ -24,15 +23,28 @@ import FarmPage from '@pages/dashboard/services/FarmCalendar/FarmLocations/Farm. import TokenRefreshPage from '@pages/auth/TokenRefresh/TokenRefresh.tsx'; import RegisterCalendarActivityPage from '@pages/dashboard/services/FarmCalendar/FarmCalendarActivities/RegisterActivity.tsx'; import EditCalendarActivityPage from '@pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx'; +import ActivityTypesPage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx'; +import AddActivityTypePage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx'; +import EditActivityTypePage from '@pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx'; import WeatherDataPage from '@pages/dashboard/services/WeatherData/WeatherData.tsx'; import EToCalculatorPage from '@pages/dashboard/services/Irrigation/EToCalculator.tsx'; import UploadDatasetPage from '@pages/dashboard/services/Irrigation/UploadDataset.tsx'; import SoilMoistureAnalysisPage from '@pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx'; +import CropTypesPage from '@pages/dashboard/services/Irrigation/CropTypes.tsx'; +import EditCropTypePage from '@pages/dashboard/services/Irrigation/EditCropType.tsx'; +import SoilTypesPage from '@pages/dashboard/services/Irrigation/SoilTypes.tsx'; +import EditSoilTypePage from '@pages/dashboard/services/Irrigation/EditSoilType.tsx'; import IrrigationOperationsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/IrrigationOperations.tsx'; import FarmAnimalsReportPage from '@pages/dashboard/services/FarmCalendar/ReportingService/FarmAnimals.tsx'; import PestsPage from '@pages/dashboard/services/PestAndDisease/Pests.tsx'; -import DiseasesPage from '@pages/dashboard/services/PestAndDisease/Diseases.tsx'; -import RiskIndexPage from '@pages/dashboard/services/PestAndDisease/RiskIndex.tsx'; +import ThreatModelsPage from '@pages/dashboard/services/PestAndDisease/ThreatModels.tsx'; +import RiskForecastPage from '@pages/dashboard/services/PestAndDisease/RiskForecast.tsx'; + +import FarmCalendarLayout from './layouts/services/FarmCalendarLayout.tsx'; +import IrrigationManagementLayout from './layouts/services/IrrigationManagementLayout.tsx'; +import PestAndDiseaseLayout from './layouts/services/PestAndDiseaseLayout.tsx'; +import ReportingLayout from './layouts/services/ReportingLayout.tsx'; +import WeatherDataLayout from './layouts/services/WeatherDataLayout.tsx'; const router = createBrowserRouter([ { @@ -48,109 +60,148 @@ const router = createBrowserRouter([ }, /** Farm calendar */ { - path: 'farm-calendar', - // children: [ - // { - // index: true, - // element: , - // }, - // /** Farm calendar */ - // { - // path: 'farm-calendar', - Component: FarmCalendarPage, - }, - { - path: 'farm-calendar/register-activity', - Component: RegisterCalendarActivityPage + Component: FarmCalendarLayout, + children: [ + { + path: 'farm-calendar', + Component: FarmCalendarPage, + }, + { + path: 'farm-calendar/register-activity', + Component: RegisterCalendarActivityPage + }, + { + path: 'farm-calendar/edit-activity/:id', + Component: EditCalendarActivityPage + }, + { + path: 'farm-calendar/activity-types', + Component: ActivityTypesPage + }, + { + path: 'farm-calendar/activity-types/add', + Component: AddActivityTypePage + }, + { + path: 'farm-calendar/activity-types/edit/:id', + Component: EditActivityTypePage + }, + { + path: 'farm-locations', + children: [ + { + index: true, + element: , + }, + { + path: 'farms', + Component: FarmsPage + }, + { + path: 'farm/:id', + Component: FarmPage + }, + { + path: 'farm-parcels', + Component: FarmParcelsPage + }, + { + path: 'farm-parcel/:id', + Component: FarmParcelPage + }, + ] + }, + ] }, + /** End of Farm calendar */ { - path: 'farm-calendar/edit-activity/:id', - Component: EditCalendarActivityPage + Component: ReportingLayout, + children: [ + { + path: 'reporting-service', + children: [ + { + index: true, + element: , + }, + { + path: 'compost-operations', + Component: CompostOperationsReportPage + }, + { + path: 'farm-animals', + Component: FarmAnimalsReportPage + }, + { + path: 'irrigation-operations', + Component: IrrigationOperationsReportPage + }, + ] + }, + ] }, { - path: 'farm-locations', + Component: IrrigationManagementLayout, children: [ { - index: true, - element: , + path: 'eto-calculator', + Component: EToCalculatorPage }, { - path: 'farms', - Component: FarmsPage + path: 'upload-dataset', + Component: UploadDatasetPage }, { - path: 'farm/:id', - Component: FarmPage + path: 'soil-moisture-analysis', + Component: SoilMoistureAnalysisPage }, { - path: 'farm-parcels', - Component: FarmParcelsPage + path: 'crop-types', + Component: CropTypesPage }, { - path: 'farm-parcel/:id', - Component: FarmParcelPage + path: 'crop-types/:id', + Component: EditCropTypePage + }, + { + path: 'soil-types', + Component: SoilTypesPage + }, + { + path: 'soil-types/:id', + Component: EditSoilTypePage }, ] }, - /** End of Farm calendar */ { - path: 'reporting-service', + Component: PestAndDiseaseLayout, children: [ { - index: true, - element: , + path: 'pests', + Component: PestsPage }, { - path: 'compost-operations', - Component: CompostOperationsReportPage + path: 'gdd', + Component: GrowingDegreeDaysPage }, { - path: 'farm-animals', - Component: FarmAnimalsReportPage + path: 'threat-models', + Component: ThreatModelsPage }, { - path: 'irrigation-operations', - Component: IrrigationOperationsReportPage + path: 'risk-forecast', + Component: RiskForecastPage }, ] }, - // ] - // }, - // { - // path: 'wkt-input', - // Component: WKTInputPage - // }, - { - path: 'eto-calculator', - Component: EToCalculatorPage - }, - { - path: 'upload-dataset', - Component: UploadDatasetPage - }, - { - path: 'soil-moisture-analysis', - Component: SoilMoistureAnalysisPage - }, - { - path: 'pests', - Component: PestsPage - }, { - path: 'gdd', - Component: GrowingDegreeDaysPage - }, - { - path: 'diseases', - Component: DiseasesPage - }, - { - path: 'risk-index', - Component: RiskIndexPage - }, - { - path: 'weather-data', - Component: WeatherDataPage + Component: WeatherDataLayout, + children: [ + { + path: 'weather-data', + Component: WeatherDataPage + }, + ] }, ] }, @@ -162,10 +213,6 @@ const router = createBrowserRouter([ path: 'sign-in', Component: SignInPage }, - { - path: 'sign-up', - Component: SignUpPage - }, { path: 'session-refresh', Component: TokenRefreshPage diff --git a/src/models/AgriculturalMachine.ts b/src/models/AgriculturalMachine.ts new file mode 100644 index 0000000..82858fe --- /dev/null +++ b/src/models/AgriculturalMachine.ts @@ -0,0 +1,18 @@ +export interface AgriculturalMachine { + "@id": string; + "@type": string; + name: string; + description: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + purchase_date: string; + manufacturer: string; + model: string; + seria_number: string; + status: number; + invalidatedAtTime: string; + dateCreated: string; + dateModified: string; +} \ No newline at end of file diff --git a/src/models/Crop.ts b/src/models/Crop.ts new file mode 100644 index 0000000..d93c53d --- /dev/null +++ b/src/models/Crop.ts @@ -0,0 +1,5 @@ +export interface Crop { + id: string; + name: string; + description: string | null; +} diff --git a/src/models/CropType.ts b/src/models/CropType.ts new file mode 100644 index 0000000..7c5bdf9 --- /dev/null +++ b/src/models/CropType.ts @@ -0,0 +1,7 @@ +export interface CropTypeModel { + id: string; + crop: string; + kc_init: number; + kc_mid: number; + kc_end: number; +} diff --git a/src/models/FarmCalendarActivities.ts b/src/models/FarmCalendarActivities.ts new file mode 100644 index 0000000..97da6c6 --- /dev/null +++ b/src/models/FarmCalendarActivities.ts @@ -0,0 +1,464 @@ +export interface BaseActivityModel { + "@id": string; + "@type": string; + activityType: { + "@id": string; + "@type": string; + }; + title: string; + details: string; +} + +export interface AddRawMaterialOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + operatedOn: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasCompostMaterial: { + "@id": string; + "@type": string; + typeName: string; + quantityValue: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + }[]; +} + +export interface CompostOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + isOperatedOn: { + "@id": string; + "@type": string; + }; + hasNestedOperation: { + "@id": string; + "@type": string; + }[]; + hasMeasurement: { + "@id": string; + "@type": string; + }[]; +} + +export interface CompostTurningOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + operatedOn: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; +} + +export interface CropGrowthStageObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasAgriCrop: { + "@id": string; + "@type": string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface CropProtectionOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + usesPesticide: { + "@id": string; + "@type": string; + }; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface CropStressIndicatorObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasAgriCrop: { + "@id": string; + "@type": string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface DiseaseDetectionModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface FertilizationOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + hasApplicationMethod: string; + usesFertilizer: { + "@id": string; + "@type": string; + }; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface GenericActivityModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; +} + +export interface GenericAlertModel extends BaseActivityModel { + severity: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + validFrom: string; + validTo: string; + dateIssued: string; + quantityValue: {}; + relatedObservation: { + "@id": string; + "@type": string; + } | null; +} + +export interface GenericAlertOptions { + actions: { + POST: { + severity: { + choices: { + value: string, + display_name: string + }[] + } + } + } +} + +export interface GenericObservationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface IrrigationOperationModel extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasAppliedAmount: { + "@id": string; + "@type": string; + unit: string; + numericValue: number; + }; + usesIrrigationSystem: string; + operatedOn: { + "@id": string; + "@type": string; + }; +} + +export interface IrrigationOperationOptions { + actions: { + POST: { + usesIrrigationSystem: { + choices: { + value: string, + display_name: string + }[] + } + } + } +} + +export interface SprayingRecommendationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; + usesPesticide: { + "@id": string; + "@type": string; + }; +} + +export interface VigorEstimationModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +export interface YieldPredictionModel extends BaseActivityModel { + phenomenonTime: string; + hasEndDatetime: string; + madeBySensor: { + "@id": string; + "@type": string; + name: string; + }; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; + hasArea: string; + hasResult: { + "@id": string; + "@type": string; + unit: string; + hasValue: string; + }; + observedProperty: string; +} + +interface AnimalActivityBase extends BaseActivityModel { + hasStartDatetime: string; + hasEndDatetime: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + hasAnimal: { + "@id": string; + "@type": string; + }; + responsibleAgent: string | null; + usesAgriculturalMachinery: { + "@id": string; + "@type": string; + }[]; + isPartOfActivity: { + "@id": string; + "@type": string; + } | null; +} + +export interface AnimalActivityModel extends AnimalActivityBase {} + +interface MeasurementValue { + "@id": string; + "@type": string; + unit: string; + hasValue: string; +} + +export interface AnimalLactatingActivityModel extends AnimalActivityBase { + hasDaysInMilk: string; + hasLactationNumber: string; + hasControl: string; + hasTotalMilkYield: MeasurementValue; + hasMilkYield: MeasurementValue; + hasRCS: MeasurementValue; + hasUrea: MeasurementValue; + hasFat: MeasurementValue; + hasProtein: MeasurementValue; + hasDryMatter: MeasurementValue; +} \ No newline at end of file diff --git a/src/models/FarmCalendarActivityType.ts b/src/models/FarmCalendarActivityType.ts new file mode 100644 index 0000000..ef822f0 --- /dev/null +++ b/src/models/FarmCalendarActivityType.ts @@ -0,0 +1,11 @@ +export interface FarmCalendarActivityTypeModel { + '@id': string; + '@type': string; + description: string; + name: string; + category: string; + background_color: string; + border_color: string; + text_color: string; + activity_endpoint: string; +} \ No newline at end of file diff --git a/src/models/FarmCrop.ts b/src/models/FarmCrop.ts new file mode 100644 index 0000000..5ec4942 --- /dev/null +++ b/src/models/FarmCrop.ts @@ -0,0 +1,21 @@ +export interface FarmCropModel { + '@id': string; + '@type': string; + cropSpecies: { + "@id": string; + "@type": string; + "name": string; + "variety": string; + }; + status: number; + invalidatedAtTime: string; + dateCreated: string; + dateModified: string; + name: string; + description: string; + hasAgriParcel: { + "@id": string; + "@type": string; + }; + growth_stage: string; +} \ No newline at end of file diff --git a/src/models/FarmParcel.ts b/src/models/FarmParcel.ts index bd71825..d5b475b 100644 --- a/src/models/FarmParcel.ts +++ b/src/models/FarmParcel.ts @@ -20,7 +20,7 @@ export interface FarmParcelModel { 'isIrrigated': boolean, 'isCultivatedInLevels': boolean, 'isGroundSlope': boolean, - 'depiction': string, + 'depiction': string | null, 'hasGeometry': { '@id': string, '@type': string, diff --git a/src/models/Fertilizer.ts b/src/models/Fertilizer.ts new file mode 100644 index 0000000..c0d83ce --- /dev/null +++ b/src/models/Fertilizer.ts @@ -0,0 +1,15 @@ +export interface FertilizerModel { + "@id": string; + "@type": string; + hasCommercialName: string; + description: string; + hasCost: string; + isPricePer: string; + hasActiveSubstance: string; + isTargetedTowards: string; + hasNutrientConcentration: string; + status: number; + dateCreated: string; + dateModified: string; + invalidatedAtTime: string; +} \ No newline at end of file diff --git a/src/models/FuzzyRiskForecast.ts b/src/models/FuzzyRiskForecast.ts new file mode 100644 index 0000000..0a7a451 --- /dev/null +++ b/src/models/FuzzyRiskForecast.ts @@ -0,0 +1,12 @@ +export type RiskClass = 'Low' | 'Moderate' | 'High' | 'Out of season'; + +export interface FuzzyRiskForecastRow { + date: string; + scientific_name: string; + common_name: string; + risk_score: number; + risk_class: RiskClass | string; + detail: string; +} + +export type FuzzyRiskForecastResponse = FuzzyRiskForecastRow[]; diff --git a/src/models/Pesticide.ts b/src/models/Pesticide.ts new file mode 100644 index 0000000..994e882 --- /dev/null +++ b/src/models/Pesticide.ts @@ -0,0 +1,15 @@ +export interface PesticideModel { + "@id": string, + "@type": string + dateCreated: string, + dateModified: string, + hasCommercialName: string, + description: string, + hasCost: string, + isPricePer: string, + hasActiveSubstance: string, + isTargetedTowards: string, + hasPreharvestInterval: number, + status: number, + invalidatedAtTime: string | null, +} \ No newline at end of file diff --git a/src/models/RiskIndex.jsonld.ts b/src/models/RiskIndex.jsonld.ts deleted file mode 100644 index bd69039..0000000 --- a/src/models/RiskIndex.jsonld.ts +++ /dev/null @@ -1,59 +0,0 @@ -export type RiskIndexStatus = 'low' | 'high'; - -export interface RiskIndexModel { - '@context': string[]; - '@graph': ObservationCollection[]; -} - -interface ObservationCollection { - '@id': string; - '@type': string[]; - description: string; - observedProperty: ObservedProperty; - madeBySensor: MadeBySensor; - hasFeatureOfInterest: FeatureOfInterest; - basedOnWeatherDataset: BasedOnWeatherDataset; - resultTime: string; - hasMember: Observation[]; -} - -interface ObservedProperty { - '@id': string; - '@type': string[]; - name: string; - hasAgriPest: AgriPest; -} - -interface AgriPest { - '@id': string; - '@type': string; - name: string; - description: string; - eppoConcept: string; -} - -interface MadeBySensor { - '@id': string; - '@type': string[]; - name: string; -} - -interface FeatureOfInterest { - '@id': string; - '@type': string[]; - long: string; - lat: string; -} - -interface BasedOnWeatherDataset { - '@id': string; - '@type': string; - name: string; -} - -interface Observation { - '@id': string; - '@type': string[]; - phenomenonTime: string; - hasSimpleResult: RiskIndexStatus; -} \ No newline at end of file diff --git a/src/models/SoilMoisture.ts b/src/models/SoilMoisture.ts index 98049ea..a5f392f 100644 --- a/src/models/SoilMoisture.ts +++ b/src/models/SoilMoisture.ts @@ -14,6 +14,14 @@ export interface DatasetRow { [key: string]: any; // Needed for chart }; +export interface DatasetResponse { + data_points: DatasetRow[]; + high_dose_irrigation_days: string[]; + field_capacity: number; + stress_level: number; + wilting_point: number; +} + export interface SoilMoistureResponseJSONLD { '@context': string[]; '@graph': SoilMoistureGraphEntry[]; @@ -72,19 +80,19 @@ interface SoilMoistureGraphEntry { }; } -export interface SoilMoistureResponseJSON { - - "dataset_id": string, - "time_period": string[], - "irrigation_events_detected": number, - "precipitation_events": number, - "high_dose_irrigation_events": number, - "high_dose_irrigation_events_dates": string[], - "field_capacity": number, - "stress_level": number, - "number_of_saturation_days": number, - "saturation_dates": string[], - "no_of_stress_days": number, - "stress_dates": string[] - +export interface SoilMoistureResponseJSON { + + "dataset_id": string, + "time_period": string[], + "irrigation_events_detected": number, + "precipitation_events": number, + "high_dose_irrigation_events": number, + "high_dose_irrigation_events_dates": string[], + "field_capacity": number, + "stress_level": number, + "number_of_saturation_days": number, + "saturation_dates": string[], + "no_of_stress_days": number, + "stress_dates": string[] + } \ No newline at end of file diff --git a/src/models/SoilType.ts b/src/models/SoilType.ts new file mode 100644 index 0000000..835a4d0 --- /dev/null +++ b/src/models/SoilType.ts @@ -0,0 +1,6 @@ +export interface SoilTypeModel { + id: string; + soil_type: string; + field_capacity: number; + wilting_point: number; +} diff --git a/src/models/ThreatModel.ts b/src/models/ThreatModel.ts new file mode 100644 index 0000000..128b4e0 --- /dev/null +++ b/src/models/ThreatModel.ts @@ -0,0 +1,61 @@ +export type RiskLevel = 'low' | 'moderate' | 'high' | 'critical'; + +export const RISK_LEVELS: RiskLevel[] = ['low', 'moderate', 'high', 'critical']; + +export interface BioParams { + t_base?: number | null; + t_lethal_min?: number | null; + t_lethal_max?: number | null; + t_optimal_min?: number | null; + t_optimal_max?: number | null; + min_streak?: number | null; + pheno_frac_lo?: number | null; + pheno_frac_hi?: number | null; + pheno_frac_ref_gdd5?: number | null; + pheno_lo?: number | null; + pheno_hi?: number | null; + min_wetness_hours_critical?: number | null; + min_wetness_hours_high?: number | null; +} + +export interface FuzzyRule { + hum_lo?: number; + hum_hi?: number; + temp_lo?: number; + temp_hi?: number; + rain_min?: number; + risk_level: RiskLevel; + type?: string | null; +} + +export interface ThreatModelDefinition { + bio_params: BioParams; + fuzzy_rules: FuzzyRule[]; +} + +export interface ThreatModel { + id: string; + scientific_name: string; + common_name: string; + label: string | null; + note: string | null; + definition: ThreatModelDefinition; + crop_id: string; +} + +export interface ThreatModelCreate { + scientific_name: string; + common_name: string; + label?: string | null; + note?: string | null; + definition: ThreatModelDefinition; + crop_id: string; +} + +export interface ThreatModelUpdate { + scientific_name?: string | null; + common_name?: string | null; + label?: string | null; + note?: string | null; + definition?: ThreatModelDefinition | null; +} diff --git a/src/pages/auth/SignIn/SignInPage.tsx b/src/pages/auth/SignIn/SignInPage.tsx index c5dae5d..efa8fed 100644 --- a/src/pages/auth/SignIn/SignInPage.tsx +++ b/src/pages/auth/SignIn/SignInPage.tsx @@ -1,4 +1,4 @@ -import { Box, Button, IconButton, InputAdornment, Link, TextField, Typography } from "@mui/material"; +import { Box, Button, IconButton, InputAdornment, TextField, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import LoginIcon from '@mui/icons-material/Login'; @@ -118,11 +118,6 @@ const SignInPage = () => { variant="contained"> Sign In - - - {'Don\'t have an account yet? Register for free'} - - { - const [credentials, setCredentials] = useState({ email: "", password: "", confirmPassword: "" }); - const [errors, setErrors] = useState({ email: "", password: "", confirmPassword: "" }); - const [showPassword, setShowPassword] = useState(false); - - const validateEmail = (email: string) => { - if (!email) return "Email is required"; - if (!/^\S+@\S+\.\S+$/.test(email)) return "Invalid email format"; - return ""; - }; - - const validatePassword = (password: string) => { - if (!password) return "Password is required"; - if (password.length < 6) return "Password must be at least 6 characters"; - return ""; - }; - - const validateConfirmPassword = (confirmPassword: string) => { - if (!confirmPassword) return "Confirm password is required"; - if (confirmPassword !== credentials.password) return "Passwords do not match"; - return ""; - }; - - const validateField = (name: string, value: string) => { - let error = ""; - if (name === "email") error = validateEmail(value); - if (name === "password") error = validatePassword(value); - if (name === "confirmPassword") error = validateConfirmPassword(value); - setErrors((prev) => ({ ...prev, [name]: error })); - }; - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setCredentials((prev) => ({ ...prev, [name]: value })); - validateField(name, value); - }; - - const { fetchData, loading, response, error } = useFetch<{ message: string }>( - "user/register", // TODO: use real API - { - headers: { - "Content-Type": "application/json", - }, - method: "POST", - body: { email: credentials.email, password: credentials.password }, - } - ); - - const handleSubmit = async () => { - const emailError = validateEmail(credentials.email); - const passwordError = validatePassword(credentials.password); - const confirmPasswordError = validateConfirmPassword(credentials.confirmPassword); - - setErrors({ email: emailError, password: passwordError, confirmPassword: confirmPasswordError }); - - if (emailError || passwordError || confirmPasswordError) return; - - await fetchData(); - }; - - const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); - const navigate = useNavigate(); - - useEffect(() => { - if (response) { - setTimeout(() => navigate("/sign-in"), 1000); - } - }, [response]); - - useEffect(() => { - if (error) { - showSnackbar("error", error?.message.toString() ?? "Registration failed"); - } - }, [error]); - - return ( - <> - - - - Sign Up - - - setShowPassword((show) => !show)} - edge="end" - > - {showPassword ? : } - - - ), - } - }} - /> - - setShowPassword((show) => !show)} - edge="end" - > - {showPassword ? : } - - - ), - } - }} - /> - - - - {"Already have an account? Sign in"} - - - - - - - ) -} - -export default SignUpPage; \ No newline at end of file diff --git a/src/pages/auth/TokenRefresh/TokenRefresh.tsx b/src/pages/auth/TokenRefresh/TokenRefresh.tsx index a3bdf6a..4c7f53c 100644 --- a/src/pages/auth/TokenRefresh/TokenRefresh.tsx +++ b/src/pages/auth/TokenRefresh/TokenRefresh.tsx @@ -57,14 +57,22 @@ const TokenRefreshPage = () => { } }, [session]); - // Navigate back to the sign in page in case of error useEffect(() => { if (error) { + if (!navigator.onLine) { + return; + } const callbackURL = encodeURIComponent(searchParams.get("callbackURL") ?? '/') navigate('/sign-in?callbackURL=' + callbackURL); } }, [error]) + useEffect(() => { + const handleOnline = () => fetchData(); + window.addEventListener('online', handleOnline); + return () => window.removeEventListener('online', handleOnline); + }, []); + return ( diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx new file mode 100644 index 0000000..e85463a --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypeForm.tsx @@ -0,0 +1,135 @@ +import { Box, Button, Card, CardContent, Stack, TextField } from "@mui/material"; +import { MuiColorInput } from "mui-color-input"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import SaveIcon from '@mui/icons-material/Save'; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; + +export type ActivityTypeFormValues = Pick< + FarmCalendarActivityTypeModel, + 'name' | 'description' | 'category' | 'background_color' | 'border_color' | 'text_color' +>; + +interface CategoryChoice { + value: string; + display_name: string; +} + +interface CategoryOptionsResponse { + actions?: { + POST?: { + category?: { + choices?: CategoryChoice[]; + }; + }; + }; +} + +interface ActivityTypeFormProps { + values: ActivityTypeFormValues; + setValues: React.Dispatch>; + onSubmit: () => void; + loading: boolean; + canEdit: boolean; + submitLabel: string; +} + +const ActivityTypeForm: React.FC = ({ values, setValues, onSubmit, loading, canEdit, submitLabel }) => { + const handleText = (key: keyof ActivityTypeFormValues) => (e: React.ChangeEvent) => { + setValues(prev => ({ ...prev, [key]: e.target.value })); + }; + + const handleColor = (key: keyof ActivityTypeFormValues) => (value: string) => { + setValues(prev => ({ ...prev, [key]: value })); + }; + + const setCategory: React.Dispatch> = (val) => { + setValues(prev => ({ + ...prev, + category: typeof val === 'function' ? (val as (p: string) => string)(prev.category) : val, + })); + }; + + const isInvalid = !values.name.trim() || !values.category.trim(); + + return ( + <> + + + + + + + canEdit={canEdit} + endpoint='proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json' + method="OPTIONS" + label='Category' + selectedValue={values.category} + setSelectedValue={setCategory} + transformResponse={r => r.actions?.POST?.category?.choices ?? []} + getOptionLabel={item => item.display_name} + getOptionValue={item => item.value} + required + error={!values.category.trim()} + /> + + + + + + + + + + + + + ); +}; + +export default ActivityTypeForm; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx new file mode 100644 index 0000000..6bc83ae --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/ActivityTypes.tsx @@ -0,0 +1,158 @@ +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useDialog from "@hooks/useDialog"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { Box, Button, IconButton, Stack, Typography } from "@mui/material"; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; + +interface ActivityTypeRow { + id: string; + name: string; + operations: null; +} + +const ActivityTypesPage = () => { + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const { fetchData: fetchList, response: listResponse, error: listError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json`, + { method: 'GET' } + ); + + const { fetchData: doDelete, response: deleteResponse, error: deleteError } = useFetch( + pendingDeleteId ? `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${pendingDeleteId}/` : '', + { method: 'DELETE' } + ); + + useEffect(() => { + fetchList(); + }, []); + + useEffect(() => { + if (listError) showSnackbar('error', 'Error loading activity types'); + }, [listError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Activity type deleted'); + setPendingDeleteId(null); + fetchList(); + } + }, [deleteResponse]); + + useEffect(() => { + if (deleteError) { + showSnackbar('error', 'Error deleting activity type'); + setPendingDeleteId(null); + } + }, [deleteError]); + + const rows: ActivityTypeRow[] = useMemo(() => { + if (!Array.isArray(listResponse)) return []; + return listResponse.map(t => ({ + id: t["@id"].split(':').pop() ?? t["@id"], + name: t.name, + operations: null, + })); + }, [listResponse]); + + const handleEdit = (id: string) => { + navigate(`edit/${id}`); + }; + + const handleDelete = (id: string) => { + setPendingDeleteId(id); + showDialog({ + title: 'Are you sure you want to delete this activity type?', + variant: 'yes-no', + children: <>, + }); + }; + + const confirmDelete = () => { + if (pendingDeleteId) { + doDelete(); + } + }; + + const headCells: HeadCell[] = [ + { id: 'name', label: 'Name', numeric: false }, + { + id: 'operations', + label: 'Operations', + numeric: false, + disableSort: true, + renderCell: (row) => ( + + { e.stopPropagation(); handleEdit(row.id); }} + aria-label="edit" + > + + + { e.stopPropagation(); handleDelete(row.id); }} + aria-label="delete" + > + + + + ), + }, + ]; + + return ( + <> + + Manage calendar activity types + + + + + data={rows} + headCells={headCells} + /> + + { dialogProps.onClose(); setPendingDeleteId(null); }} + onYes={confirmDelete} + /> + + + ); +}; + +export default ActivityTypesPage; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx new file mode 100644 index 0000000..d449f2b --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/AddActivityType.tsx @@ -0,0 +1,70 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { Box, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import ActivityTypeForm, { ActivityTypeFormValues } from "./ActivityTypeForm"; + +const emptyValues: ActivityTypeFormValues = { + name: '', + description: '', + category: '', + background_color: '#1976d2', + border_color: '#1976d2', + text_color: '#ffffff', +}; + +const AddActivityTypePage = () => { + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canEdit = actions.includes('add'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const [values, setValues] = useState(emptyValues); + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/`, + { method: 'POST' } + ); + + const handleSubmit = () => { + fetchData({ body: values }); + }; + + useEffect(() => { + if (response) { + showSnackbar('success', 'Activity type created'); + navigate('/farm-calendar/activity-types'); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'Error creating activity type'); + }, [error]); + + return ( + <> + + Add calendar activity type + + + + + ); +}; + +export default AddActivityTypePage; diff --git a/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx new file mode 100644 index 0000000..459a3ae --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/ActivityTypes/EditActivityType.tsx @@ -0,0 +1,98 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { Box, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import ActivityTypeForm, { ActivityTypeFormValues } from "./ActivityTypeForm"; + +const emptyValues: ActivityTypeFormValues = { + name: '', + description: '', + category: '', + background_color: '#1976d2', + border_color: '#1976d2', + text_color: '#ffffff', +}; + +const EditActivityTypePage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const [values, setValues] = useState(emptyValues); + + const { fetchData: fetchOne, response: getResponse, error: getError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${id}/?format=json`, + { method: 'GET' } + ); + + const { fetchData: doPatch, response: patchResponse, error: patchError, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/${id}/`, + { method: 'PATCH' } + ); + + useEffect(() => { + if (id) fetchOne(); + }, [id]); + + useEffect(() => { + if (getResponse) { + setValues({ + name: getResponse.name ?? '', + description: getResponse.description ?? '', + category: getResponse.category ?? '', + background_color: getResponse.background_color ?? '#1976d2', + border_color: getResponse.border_color ?? '#1976d2', + text_color: getResponse.text_color ?? '#ffffff', + }); + } + }, [getResponse]); + + useEffect(() => { + if (getError) showSnackbar('error', 'Error loading activity type'); + }, [getError]); + + const handleSubmit = () => { + doPatch({ body: values }); + }; + + useEffect(() => { + if (patchResponse) { + showSnackbar('success', 'Activity type updated'); + navigate('/farm-calendar/activity-types'); + } + }, [patchResponse]); + + useEffect(() => { + if (patchError) showSnackbar('error', 'Error updating activity type'); + }, [patchError]); + + return ( + <> + + Edit calendar activity type + + + + + ); +}; + +export default EditActivityTypePage; diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx index 0cb48b3..4105b70 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendar.tsx @@ -1,26 +1,70 @@ import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; import useSnackbar from "@hooks/useSnackbar"; -// import { Box, Button } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import { useEffect, useMemo, useState } from "react"; import { EventInput } from '@fullcalendar/core'; import useFetch from "@hooks/useFetch"; import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useOutletContext } from "react-router-dom"; import StyledFullCalendar from "@components/shared/styled/StyledFullCalendar/StyledFullCalendar"; import dayjs from "dayjs"; import { useSession } from "@contexts/SessionContext"; import ContentGuard from "@components/shared/ContentGuard/ContentGuard"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; const FarmCalendarPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const navigate = useNavigate(); const [dateRange, setDateRange] = useState<{ start: string | null, end: string | null }>({ start: null, end: null }); + const [activityTypes, setActivityTypes] = useState([]); + const { session } = useSession(); - const { fetchData, response, error } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${session?.farm_parcel?.["@id"].split(':')[3]}&format=json&fromDate=${dayjs(dateRange.start).format('YYYY-MM-DD')}&toDate=${dayjs(dateRange.end).format('YYYY-MM-DD')}`, + const [activities, setActivities] = useState([]); + const [activitiesError, setActivitiesError] = useState(null); + + const fetchActivitiesByMonth = async () => { + if (!dateRange.start || !dateRange.end || !session?.farm_parcel || !session.user.token) return; + const parcelId = session.farm_parcel["@id"].split(':')[3]; + const apiUrl = (window as any).env?.VITE_API_URL ?? import.meta.env.VITE_API_URL; + const months: { from: string; to: string }[] = []; + let cursor = dayjs(dateRange.start).startOf('month'); + const end = dayjs(dateRange.end).endOf('month'); + while (cursor.isBefore(end) || cursor.isSame(end, 'month')) { + months.push({ + from: cursor.startOf('month').format('YYYY-MM-DD'), + to: cursor.endOf('month').format('YYYY-MM-DD'), + }); + cursor = cursor.add(1, 'month'); + } + setActivitiesError(null); + try { + const responses = await Promise.all(months.map(m => + fetch(`${apiUrl}proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${parcelId}&format=json&fromDate=${m.from}&toDate=${m.to}`, { + headers: { Authorization: `Bearer ${session.user.token}` }, + }).then(r => r.ok ? r.json() as Promise : Promise.reject(new Error(`HTTP ${r.status}`))) + )); + const merged = responses.flat(); + const seen = new Set(); + const deduped = merged.filter(a => { + if (seen.has(a["@id"])) return false; + seen.add(a["@id"]); + return true; + }); + setActivities(deduped); + } catch (err) { + setActivitiesError(err as Error); + } + }; + + const { fetchData: activityTypesFetchData, response: activityTypesResponse, error: activityTypesError } = useFetch( + `proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json`, { method: 'GET', } @@ -28,23 +72,36 @@ const FarmCalendarPage = () => { const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + useEffect(() => { + activityTypesFetchData(); + }, []) + + useEffect(() => { + if (activityTypesResponse) { + setActivityTypes(activityTypesResponse); + } + }, [activityTypesResponse]) + + useEffect(() => { + if (activityTypesError) { + showSnackbar('error', 'Error loading activity types'); + } + }, [activityTypesError]) + useEffect(() => { if (dateRange.start && dateRange.end && session?.farm_parcel) { - fetchData(); + fetchActivitiesByMonth(); } }, [session?.farm_parcel, dateRange]) useEffect(() => { - if (error) { + if (activitiesError) { showSnackbar('error', 'Error loading activities'); } - }, [error]) + }, [activitiesError]) const calendarEvents = useMemo(() => { - if (!Array.isArray(response)) { - return []; - } - return response.map((event): EventInput => ({ + return activities.map((event): EventInput => ({ id: event['@id'], title: event.title, start: event.hasStartDatetime, @@ -54,21 +111,32 @@ const FarmCalendarPage = () => { activityType: event.activityType, } })); - }, [response]); + }, [activities]); return ( <> <> - {/* - - */} + + + + { - navigate(`edit-activity/${info.event.id.split(":")[3]}`) + const api = activityTypes.find(a => { return a["@id"].split(":")[3] === info.event.extendedProps.activityType["@id"].split(":")[3] })?.activity_endpoint + navigate(`edit-activity/${info.event.id.split(":")[3]}`, { state: { api: api, activityTypes: activityTypes } }) } } onDateRangeChange={setDateRange} diff --git a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx index 86a28b1..72bfcd7 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmCalendarActivities/EditActivity.tsx @@ -1,206 +1,168 @@ -import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; -import useDialog from "@hooks/useDialog"; import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; -import { FarmCalendarActivityModel } from "@models/FarmCalendarActivity"; -import { FarmParcelModel } from "@models/FarmParcel"; -import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; -import { DateTimePicker } from "@mui/x-date-pickers"; -import dayjs, { Dayjs } from "dayjs"; +import { Box, Skeleton, Typography } from "@mui/material"; import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; - -import SaveIcon from '@mui/icons-material/Save'; -import DeleteIcon from '@mui/icons-material/Delete'; -import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import { useLocation, useNavigate, useOutletContext, useParams } from "react-router-dom"; + +import { + BaseActivityModel, + AddRawMaterialOperationModel, + AnimalActivityModel, + AnimalLactatingActivityModel, + CompostOperationModel, + CompostTurningOperationModel, + CropGrowthStageObservationModel, + CropProtectionOperationModel, + CropStressIndicatorObservationModel, + DiseaseDetectionModel, + FertilizationOperationModel, + GenericActivityModel, + GenericAlertModel, + GenericObservationModel, + IrrigationOperationModel, + SprayingRecommendationModel, + VigorEstimationModel, + YieldPredictionModel +} from '@models/FarmCalendarActivities'; +import ActivityDynamicCRUDActions from "@components/dashboard/services/FarmCalendar/ActivityDynamicCRUDActions/ActivityDynamicCRUDActions"; +import { FarmCalendarActivityTypeModel } from "@models/FarmCalendarActivityType"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; + +const ActivityFormComponentMap: { [key: string]: React.FC } = { + 'AddRawMaterialOperation': (props) => {...props} />, + 'CompostOperation': (props) => {...props} />, + 'CompostTurningOperation': (props) => {...props} />, + 'CropGrowthStageObservation': (props) => {...props} />, + 'CropProtectionOperation': (props) => {...props} />, + 'CropStressIndicatorObservation': (props) => {...props} />, + 'DiseaseDetection': (props) => {...props} />, + 'FertilizationOperation': (props) => {...props} />, + 'FarmCalendarActivity': (props) => {...props} />, + 'Alert': (props) => {...props} />, + 'Observation': (props) => {...props} />, + 'IrrigationOperation': (props) => {...props} />, + 'SprayingRecommendation': (props) => {...props} />, + 'VigorEstimation': (props) => {...props} />, + 'YieldPrediction': (props) => {...props} />, + 'AnimalActivity': (props) => {...props} />, + 'AnimalLactatingActivity': (props) => {...props} />, +}; + +interface LocationState { + api: string | undefined; + activityTypes: FarmCalendarActivityTypeModel[] | undefined; +} const EditCalendarActivityPage = () => { - const [activity, setActivity] = useState(); - const [title, setTitle] = useState(''); - - const [selectedParcel, setSelectedParcel] = useState(''); + const [activityData, setActivityData] = useState(null); + const [pageTitle, setPageTitle] = useState(''); const { id } = useParams(); - const { fetchData, loading, response, error } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'GET', - } - ); + const location = useLocation(); + const navigate = useNavigate(); - const { fetchData: editFetchData, response: editResponse, error: editError } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'PUT', - } - ); + const state = location.state as LocationState; + const api = state?.api; + const activityTypes = state?.activityTypes; - const { fetchData: deleteFetchData, error: deleteError } = useFetch( - `proxy/farmcalendar/api/v1/FarmCalendarActivities/${id}/?format=json`, - { - method: 'DELETE', - } + const { fetchData, loading, response, error } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'GET' } + ); + const { fetchData: patchFetchData, loading: editLoading, response: editResponse, error: editError } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'PUT' } + ); + const { fetchData: deleteFetchData, loading: deleteLoading, response: deleteResponse, error: deleteError } = useFetch( + api ? `proxy/farmcalendar${api}${id}/?format=json` : '', + { method: 'DELETE' } ); - - const { dialogProps, showDialog } = useDialog(); - - const handleCloseDialog = () => { - dialogProps.onClose(); - }; const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const isMutating = editLoading || deleteLoading; useEffect(() => { - fetchData(); - }, []) - - useEffect(() => { - if (error) { - showSnackbar('error', 'Error loading activity'); + if (api) fetchData(); + else { + showSnackbar('error', 'API path not specified.'); + navigate('/farm-calendar'); } - }, [error]) + }, [api]); useEffect(() => { - if (editError) { - showSnackbar('error', 'Error editing activity'); - } - }, [editError]) - - useEffect(() => { - if (deleteError) { - showSnackbar('error', 'Error deleting activity'); - } - }, [deleteError]) + if (error) showSnackbar('error', 'Error loading activity'); + if (editError) showSnackbar('error', 'Error editing activity'); + if (deleteError) showSnackbar('error', 'Error deleting activity'); + }, [error, editError, deleteError]); useEffect(() => { if (response) { - setActivity(response); - setTitle(response.title); - setSelectedParcel(response.hasAgriParcel["@id"].split(':')[3]); + setActivityData(response); + setPageTitle(`Edit ${response.title}`); } - }, [response]) + }, [response]); - const navigate = useNavigate(); useEffect(() => { if (editResponse) { + showSnackbar('success', 'Activity updated successfully.'); + navigate("/farm-calendar"); + } + if (deleteResponse) { + showSnackbar('success', 'Activity deleted successfully.'); navigate("/farm-calendar"); } - }, [editResponse]) + }, [editResponse, deleteResponse]); - const handleDelete = async () => { - await deleteFetchData(); - navigate("/farm-calendar"); + const handlePatch = (activityToSave: BaseActivityModel) => { + patchFetchData({ body: activityToSave }); }; - const handleEdit = () => { - editFetchData({ - body: { - activityType: activity?.activityType["@id"].split(':')[3], - title: activity?.title, - details: activity?.details, - hasStartDatetime: activity?.hasStartDatetime, - hasEndDatetime: activity?.hasEndDatetime, - hasAgriParcel: selectedParcel, - usesAgriculturalMachinery: [] // TODO: change - // responsibleAgent - } - }); + const handleDelete = () => { + deleteFetchData(); }; + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + const renderForm = () => { + if (!activityData) return null; - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - // const isNumericField = name === 'base_gdd'; - // const finalValue = isNumericField ? parseFloat(value) : value; - setActivity(prev => prev ? { ...prev, [name]: value } : undefined); - }; + const activityType = activityData['@type']; + const FormComponent = ActivityFormComponentMap[activityType]; - const handleDateChange = ( - newValue: Dayjs | null, - fieldName: 'hasStartDatetime' | 'hasEndDatetime' - ) => { - const formattedValue = newValue ? newValue.toISOString() : null; - setActivity(prev => prev ? { ...prev, [fieldName]: formattedValue } : undefined); - }; + if (!FormComponent) { + return Unknown activity type: {activityType}; + } - const isFormInvalid = - !activity?.title?.trim() || - !activity.hasStartDatetime || - !activity.hasEndDatetime || - !selectedParcel + return ( + + ); + }; return ( <> - {loading && } - { - !(loading || error) && - - Edit {title} - - - - - handleDateChange(newValue, 'hasStartDatetime')} - /> - handleDateChange(newValue, 'hasEndDatetime')} - /> - - {activity && - endpoint='proxy/farmcalendar/api/v1/FarmParcels/?format=json' - label='Has Parcel' - selectedValue={selectedParcel} - setSelectedValue={setSelectedParcel} - getOptionLabel={item => `${item.identifier} (${item.category})`} - getOptionValue={item => item["@id"].split(':')[3]}> - } - {/* Hardcoded */} - - - - {/* Hardcoded */} - - - - - - - - - } - + + {pageTitle} + + {loading && } + + {!loading && activityData && renderForm()} + + {error && !loading && ( + Could not load activity details. + )} + + } = { + 'AddRawMaterialOperation': (props) => {...props} />, + 'CompostOperation': (props) => {...props} />, + 'CompostTurningOperation': (props) => {...props} />, + 'CropGrowthStageObservation': (props) => {...props} />, + 'CropProtectionOperation': (props) => {...props} />, + 'CropStressIndicatorObservation': (props) => {...props} />, + 'DiseaseDetection': (props) => {...props} />, + 'FertilizationOperation': (props) => {...props} />, + 'FarmCalendarActivity': (props) => {...props} />, + 'Alert': (props) => {...props} />, + 'Observation': (props) => {...props} />, + 'IrrigationOperation': (props) => {...props} />, + 'SprayingRecommendation': (props) => {...props} />, + 'VigorEstimation': (props) => {...props} />, + 'YieldPrediction': (props) => {...props} />, + 'AnimalActivity': (props) => {...props} />, + 'AnimalLactatingActivity': (props) => {...props} />, +}; +interface LocationState { + activityTypes: FarmCalendarActivityTypeModel[] | undefined; +} + const RegisterCalendarActivityPage = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + const state = location.state as LocationState; + const activityTypes = state?.activityTypes; + + const [selectedActivityType, setSelectedActivityType] = useState(''); + const [api, setAPI] = useState(''); + + const [activityData, setActivityData] = useState(null); + + const { fetchData, loading, response, error } = useFetch( + api ? `proxy/farmcalendar${api}` : '', + { method: 'POST' } + ); + + useEffect(() => { + if (selectedActivityType) { + const selectedActivityTypeObject = activityTypes?.find(at => at["@id"] === selectedActivityType); + const api = selectedActivityTypeObject?.activity_endpoint ?? ''; + setAPI(api); + + const getEmptyModel = activityModelFactory[api]; + + if (getEmptyModel && selectedActivityTypeObject) { + setActivityData(getEmptyModel(selectedActivityTypeObject["@id"].split(':')[3])); + } else { + console.warn(`No empty model factory found for endpoint: ${api}`); + setActivityData(null); + } + } else { + setActivityData(null); + } + }, [selectedActivityType]); + + const handlePost = (activityToSave: BaseActivityModel) => { + console.log({ body: activityToSave }); + fetchData({ body: activityToSave }) + }; + + useEffect(() => { + if (error) showSnackbar('error', 'Error adding activity'); + }, [error]); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Activity updated successfully.'); + navigate("/farm-calendar"); + } + }, [response]); + + const { actions } = useOutletContext(); + const canEdit = actions.includes('add'); + + const renderForm = () => { + if (!activityData) return null; + + const activityType = activityData['@type']; + const FormComponent = ActivityFormComponentMap[activityType]; + + if (!FormComponent) { + return Unknown activity type: {activityType}; + } + + return ( + + ); + }; + return ( <> -
register activity works
+ + Register new activity + + + endpoint='' + data={activityTypes} + label='Activity type' + selectedValue={selectedActivityType} + setSelectedValue={setSelectedActivityType} + getOptionLabel={item => `${item.name}`} + getOptionValue={item => item["@id"]} + /> + + {activityData && renderForm()} + + ) } diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx new file mode 100644 index 0000000..073de78 --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { AddFarmProps } from "./AddFarm.types"; +import { FarmModel } from "@models/Farm"; +import { Box, Button, Stack, TextField } from "@mui/material"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; + +const AddFarm: React.FC = ({ onAction }) => { + const [formData, setFormData] = useState({ + '@type': '', + '@id': '', + status: 0, + deleted_at: null, + created_at: '', + updated_at: '', + name: '', + description: '', + administrator: '', + telephone: '', + vatID: '', + hasAgriParcel: [], + contactPerson: { + firstname: '', + lastname: '', + '@id': '', + '@type': '', + }, + address: { + '@id': '', + '@type': '', + adminUnitL1: '', + adminUnitL2: '', + addressArea: '', + municipality: '', + community: '', + locatorName: '', + } + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const keys = name.split('.'); + + setFormData(prev => { + const newState = JSON.parse(JSON.stringify(prev)) as FarmModel; + + let currentLevel: any = newState; + try { + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + const finalKey = keys[keys.length - 1]; + const isNumeric = typeof currentLevel[finalKey] === 'number'; + currentLevel[finalKey] = isNumeric ? parseFloat(value) || 0 : value; + + } catch (error) { + console.error(`Error setting nested property "${name}":`, error); + return prev; + } + return newState; + }); + }; + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/Farm/`, + { + method: 'POST', + body: formData + } + ); + + const handlePost = () => { + console.log("Form Data:", formData); + fetchData(); + }; + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + + useEffect(() => { + if (response) { + onAction && onAction(); + showSnackbar('success', "Pest added successfully"); + } + }, [response]); + + useEffect(() => { + if (error) { + showSnackbar('error', "An error occurred"); + } + }, [error]); + + const isFormInvalid = + !formData?.name?.trim() || + !formData.description?.trim() || + !formData.administrator?.trim() || + !formData.contactPerson.firstname?.trim() || + !formData.contactPerson.lastname?.trim() || + !formData.telephone?.trim() || + !formData.vatID?.trim() || + !formData.address.adminUnitL1?.trim() || + !formData.address.adminUnitL2?.trim() || + !formData.address.addressArea?.trim() || + !formData.address.municipality?.trim() || + !formData.address.community?.trim() || + !formData.address.locatorName?.trim(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default AddFarm; \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts new file mode 100644 index 0000000..0c40696 --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarm/AddFarm.types.ts @@ -0,0 +1,3 @@ +export interface AddFarmProps { + onAction?: () => void +} \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx new file mode 100644 index 0000000..ae3218d --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.tsx @@ -0,0 +1,244 @@ +import { Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; +import { AddFarmParcelProps } from "./AddFarmParcel.types"; +import { useEffect, useState } from "react"; +import { FarmParcelModel } from "@models/FarmParcel"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { FarmModel } from "@models/Farm"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import WKTPolygonMap from "@components/shared/WKTPolygonMap/WKTPolygonMap"; + +const REQUIRED_KEYS = new Set([ + 'identifier', + 'category', + 'farm', + 'hasGeometry.asWKT', + 'location.lat', + 'location.long', + 'validFrom', + 'validTo', + 'inRegion', + 'hasToponym', + 'area', +]); + +const isReq = (key: string) => REQUIRED_KEYS.has(key); + +const AddFarmParcel: React.FC = ({ onAction }) => { + + const [selectedFarm, setSelectedFarm] = useState(''); + + const [formData, setFormData] = useState({ + '@type': '', + '@id': '', + 'status': 0, + 'deleted_at': null, + 'created_at': '', + 'updated_at': '', + 'identifier': '', + 'description': '', + 'validFrom': new Date().toISOString(), + 'validTo': new Date().toISOString(), + 'area': '', + 'hasIrrigationFlow': '', + 'category': '', + 'inRegion': '', + 'hasToponym': '', + 'isNitroArea': false, + 'isNatura2000Area': false, + 'isPdopgArea': false, + 'isIrrigated': false, + 'isCultivatedInLevels': false, + 'isGroundSlope': false, + 'depiction': null, + 'hasGeometry': { + '@id': '', + '@type': '', + 'asWKT': '' + }, + 'location': { + '@id': '', + '@type': '', + 'lat': null, + 'long': null + }, + 'hasAgriCrop': [], + 'farm': { + '@type': 'Farm', + '@id': '', + } + }); + + const { fetchData, response, error, loading } = useFetch( + `proxy/farmcalendar/api/v1/FarmParcels/`, + { + method: 'POST' + } + ); + + const handlePost = () => { + const body = JSON.parse(JSON.stringify(formData)) as FarmParcelModel; + (body.farm as { '@id': string })['@id'] = `urn:farmcalendar:Farm:${selectedFarm}`; + + if (typeof body.depiction === 'string' && !body.depiction.trim()) body.depiction = null; + + fetchData({ body }); + }; + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + if (response) { + onAction && onAction(); + showSnackbar('success', "Farm parcel added successfully"); + } + }, [response]); + + useEffect(() => { + if (error) { + showSnackbar('error', "An error occurred"); + } + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const checked = (e.target as HTMLInputElement).checked; + const keys = name.split('.'); + + const isNumericField = + name === 'area' || + name === 'hasIrrigationFlow' || + name === 'location.lat' || + name === "location.long"; + + setFormData(prev => { + if (!prev) return undefined; + + const newState = JSON.parse(JSON.stringify(prev)) as FarmParcelModel; + let currentLevel: any = newState; + for (let i = 0; i < keys.length - 1; i++) { + currentLevel = currentLevel[keys[i]]; + } + + let finalValue: string | number | boolean | null = type === 'checkbox' ? checked : value; + if (isNumericField) { + finalValue = value === '' ? null : parseFloat(value); + } + + currentLevel[keys[keys.length - 1]] = finalValue; + + return newState; + }); + }; + + const handleGeometryChange = (wkt: string) => { + setFormData(prev => { + if (!prev) return undefined; + return { ...prev, hasGeometry: { ...prev.hasGeometry, asWKT: wkt } }; + }); + }; + + const fieldEmpty = (key: string): boolean => { + if (!formData) return true; + switch (key) { + case 'identifier': return !formData.identifier?.trim(); + case 'category': return !formData.category?.trim(); + case 'farm': return !selectedFarm; + case 'hasGeometry.asWKT': return !formData.hasGeometry?.asWKT; + case 'location.lat': return formData.location.lat == null || Number.isNaN(formData.location.lat); + case 'location.long': return formData.location.long == null || Number.isNaN(formData.location.long); + case 'validFrom': return !formData.validFrom; + case 'validTo': return !formData.validTo; + case 'inRegion': return !formData.inRegion?.trim(); + case 'hasToponym': return !formData.hasToponym?.trim(); + case 'area': return formData.area == null || formData.area === '' || Number.isNaN(formData.area as any); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); + + return ( + <> + + + + { + endpoint='proxy/farmcalendar/api/v1/Farm/?format=json' + label='Selected farm' + required={isReq('farm')} + error={isReq('farm') && fieldEmpty('farm')} + selectedValue={selectedFarm} + setSelectedValue={setSelectedFarm} + getOptionLabel={item => `${item.name}`} + getOptionValue={item => item["@id"].split(':')[3]}> + } + + + + + + + + + + + + + + + } label="Nitro area" /> + } label="Natura 2000 area" /> + } label="PDOPG area" /> + } label="Irrigated" /> + } label="Cultivated in levels" /> + } label="Ground slope" /> + + + + + + + + + + + + + Parcel boundary{isReq('hasGeometry.asWKT') ? ' *' : ''} + + + + + + + + + + + + ) +} + +export default AddFarmParcel; diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts new file mode 100644 index 0000000..215cf7c --- /dev/null +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/AddFarmParcel/AddFarmParcel.types.ts @@ -0,0 +1,3 @@ +export interface AddFarmParcelProps { + onAction?: () => void +} \ No newline at end of file diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx index 0871682..488ff30 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farm.tsx @@ -2,16 +2,23 @@ import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar" import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; -import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { FarmParcelModel } from "@models/FarmParcel"; +import { Box, Button, Card, CardActionArea, CardContent, Chip, Grid, Skeleton, Stack, TextField, Tooltip, Typography } from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; +import PolylineIcon from '@mui/icons-material/Polyline'; import useDialog from "@hooks/useDialog"; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; const FarmPage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + const [farm, setFarm] = useState(); const [title, setTitle] = useState(''); @@ -37,6 +44,11 @@ const FarmPage = () => { } ); + const { fetchData: fetchParcels, response: parcelsResponse, loading: parcelsLoading, error: parcelsError } = useFetch( + `proxy/farmcalendar/api/v1/FarmParcels/?format=json`, + { method: 'GET' }, + ); + const { dialogProps, showDialog } = useDialog(); const handleCloseDialog = () => { @@ -47,8 +59,20 @@ const FarmPage = () => { useEffect(() => { fetchData(); + fetchParcels(); }, []) + useEffect(() => { + if (parcelsError) { + showSnackbar('error', 'Error loading parcels'); + } + }, [parcelsError]) + + const farmParcels = useMemo(() => { + if (!Array.isArray(parcelsResponse) || !farm) return []; + return parcelsResponse.filter(p => p.farm?.["@id"] === farm["@id"]); + }, [parcelsResponse, farm]) + useEffect(() => { if (error) { showSnackbar('error', 'Error loading farm'); @@ -112,19 +136,19 @@ const FarmPage = () => { const isFormInvalid = - !farm?.name.trim() || - !farm.administrator || - !farm.description || - !farm.contactPerson.firstname || - !farm.contactPerson.lastname || - !farm.telephone || - !farm.vatID || - !farm.address.adminUnitL1 || - !farm.address.adminUnitL2 || - !farm.address.addressArea || - !farm.address.municipality || - !farm.address.community || - !farm.address.locatorName; + !farm?.name?.trim() || + !farm.administrator?.trim() || + !farm.description?.trim() || + !farm.contactPerson?.firstname?.trim() || + !farm.contactPerson?.lastname?.trim() || + !farm.telephone?.trim() || + !farm.vatID?.trim() || + !farm.address?.adminUnitL1?.trim() || + !farm.address?.adminUnitL2?.trim() || + !farm.address?.addressArea?.trim() || + !farm.address?.municipality?.trim() || + !farm.address?.community?.trim() || + !farm.address?.locatorName?.trim(); return ( <> @@ -136,26 +160,26 @@ const FarmPage = () => { - - - + + + - - + + - - + + - - + + - - + + - - + + @@ -167,7 +191,7 @@ const FarmPage = () => { startIcon={} loading={loading} loadingPosition="start" - disabled={isFormInvalid} + disabled={isFormInvalid || !canEdit} onClick={handleEdit} > Save Changes @@ -178,6 +202,7 @@ const FarmPage = () => { startIcon={} loading={loading} loadingPosition="start" + disabled={!canDelete} onClick={() => { showDialog({ title: `Are you sure you want to delete this farm?`, @@ -189,6 +214,78 @@ const FarmPage = () => { Delete
+ + + Parcels ({farmParcels.length}) + + {parcelsLoading && ( + + {Array.from({ length: 3 }).map((_, i) => ( + + + + ))} + + )} + {!parcelsLoading && farmParcels.length === 0 && ( + No parcels assigned to this farm. + )} + {!parcelsLoading && farmParcels.length > 0 && ( + + {farmParcels.map(p => { + const parcelId = p["@id"].split(':').pop() ?? ''; + const hasPolygon = !!p.hasGeometry?.asWKT?.trim(); + return ( + + + navigate(`/farm-locations/farm-parcel/${parcelId}`)} + sx={{ height: '100%' }} + > + + + + {p.identifier || 'Unnamed parcel'} + + {hasPolygon && ( + + + + )} + + + {p.category && } + {p.area && } + + + {p.inRegion && ( + + Region: {p.inRegion} + + )} + {p.hasToponym && ( + + Toponym: {p.hasToponym} + + )} + {p.description && ( + + {p.description} + + )} + + + + + + ); + })} + + )} +
} diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx index 060baa0..18314f2 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcel.tsx @@ -6,14 +6,36 @@ import { FarmModel } from "@models/Farm"; import { FarmParcelModel } from "@models/FarmParcel"; import { Box, Button, Card, CardContent, Checkbox, FormControlLabel, Skeleton, Stack, TextField, Typography } from "@mui/material"; import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; import useDialog from "@hooks/useDialog"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; +import WKTPolygonMap from "@components/shared/WKTPolygonMap/WKTPolygonMap"; + +const REQUIRED_KEYS = new Set([ + 'identifier', + 'category', + 'farm', + 'hasGeometry.asWKT', + 'location.lat', + 'location.long', + 'validFrom', + 'validTo', + 'inRegion', + 'hasToponym', + 'area', +]); + +const isReq = (key: string) => REQUIRED_KEYS.has(key); const FarmParcelPage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + const [parcel, setParcel] = useState(); const [title, setTitle] = useState(''); @@ -92,9 +114,10 @@ const FarmParcelPage = () => { }; const handleEdit = () => { - editFetchData({ - body: parcel - }); + if (!parcel) return; + const body = JSON.parse(JSON.stringify(parcel)) as FarmParcelModel; + if (typeof body.depiction === 'string' && !body.depiction.trim()) body.depiction = null; + editFetchData({ body }); }; const handleChange = (e: React.ChangeEvent) => { @@ -117,9 +140,9 @@ const FarmParcelPage = () => { currentLevel = currentLevel[keys[i]]; } - let finalValue: string | number | boolean = type === 'checkbox' ? checked : value; + let finalValue: string | number | boolean | null = type === 'checkbox' ? checked : value; if (isNumericField) { - finalValue = parseFloat(value); + finalValue = value === '' ? null : parseFloat(value); } currentLevel[keys[keys.length - 1]] = finalValue; @@ -128,12 +151,32 @@ const FarmParcelPage = () => { }); }; - const isFormInvalid = - !parcel?.identifier?.trim() || - !parcel.category || - !parcel.location.lat || - !parcel.location.long || - !selectedFarm + const handleGeometryChange = (wkt: string) => { + setParcel(prev => { + if (!prev) return undefined; + return { ...prev, hasGeometry: { ...prev.hasGeometry, asWKT: wkt } }; + }); + }; + + const fieldEmpty = (key: string): boolean => { + if (!parcel) return true; + switch (key) { + case 'identifier': return !parcel.identifier?.trim(); + case 'category': return !parcel.category?.trim(); + case 'farm': return !selectedFarm; + case 'hasGeometry.asWKT': return !parcel.hasGeometry?.asWKT; + case 'location.lat': return parcel.location.lat == null || Number.isNaN(parcel.location.lat); + case 'location.long': return parcel.location.long == null || Number.isNaN(parcel.location.long); + case 'validFrom': return !parcel.validFrom; + case 'validTo': return !parcel.validTo; + case 'inRegion': return !parcel.inRegion?.trim(); + case 'hasToponym': return !parcel.hasToponym?.trim(); + case 'area': return parcel.area == null || parcel.area === '' || Number.isNaN(parcel.area as any); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); return ( <> @@ -146,42 +189,56 @@ const FarmParcelPage = () => { {parcel && + canEdit={canEdit} endpoint='proxy/farmcalendar/api/v1/Farm/?format=json' - label='Selected farm *' + label='Selected farm' + required={isReq('farm')} + error={isReq('farm') && fieldEmpty('farm')} selectedValue={selectedFarm} setSelectedValue={setSelectedFarm} getOptionLabel={item => `${item.name}`} getOptionValue={item => item["@id"].split(':')[3]}> } - - + + - + - - + + - - + + - } label="Nitro area" /> - } label="Natura 2000 area" /> - } label="PDOPG area" /> - } label="Irrigated" /> - } label="Cultivated in levels" /> - } label="Ground slope" /> + {}} />} label="Nitro area" /> + {}} />} label="Natura 2000 area" /> + {}} />} label="PDOPG area" /> + {}} />} label="Irrigated" /> + {}} />} label="Cultivated in levels" /> + {}} />} label="Ground slope" /> - - + + - + - - + + + + + + Parcel boundary{isReq('hasGeometry.asWKT') ? ' *' : ''} + + @@ -193,7 +250,7 @@ const FarmParcelPage = () => { startIcon={} loading={loading} loadingPosition="start" - disabled={isFormInvalid} + disabled={isFormInvalid || !canEdit} onClick={handleEdit} > Save Changes @@ -204,6 +261,7 @@ const FarmParcelPage = () => { startIcon={} loading={loading} loadingPosition="start" + disabled={!canDelete} onClick={() => { showDialog({ title: `Are you sure you want to delete this parcel?`, diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx index 3acfd76..e541c2d 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/FarmParcels.tsx @@ -5,14 +5,27 @@ import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; import { FarmParcelModel } from "@models/FarmParcel"; -import { Skeleton } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from "@mui/material"; import dayjs from "dayjs"; import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import AddFarmParcel from "./AddFarmParcel/AddFarmParcel"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; + const FarmParcelsPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const [parcels, setParcels] = useState([]); + const [expanded, setExpanded] = useState(false); + + const handleAccordionChange = () => { + setExpanded(!expanded); + }; + const { fetchData, loading, response, error } = useFetch( "proxy/farmcalendar/api/v1/FarmParcels/?format=json", { @@ -78,8 +91,22 @@ const FarmParcelsPage = () => { const handleRowClick = (parcel: ParcelRow) => { navigate(`../farm-parcel/${parcel.id.split(":")[3]}`); }; + + const onAddNewFarmParcel = () => { + fetchData(); + setExpanded(false); + }; + return ( - <> + + + }> + Add new farm parcel + + + + + {loading || farmsLoading && } { !(loading || farmsLoading) && !error && @@ -91,7 +118,7 @@ const FarmParcelsPage = () => { open={snackbarState.open} onClose={closeSnackbar} /> - + ) } diff --git a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx index f762a09..66a1fe4 100644 --- a/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx +++ b/src/pages/dashboard/services/FarmCalendar/FarmLocations/Farms.tsx @@ -4,13 +4,25 @@ import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortabl import useFetch from "@hooks/useFetch"; import useSnackbar from "@hooks/useSnackbar"; import { FarmModel } from "@models/Farm"; -import { Skeleton } from "@mui/material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from "@mui/material"; import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import AddFarm from "./AddFarm/AddFarm"; +import { ServiceContextType } from "@layouts/services/FarmCalendarLayout"; const FarmsPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const [farms, setFarms] = useState([]); + const [expanded, setExpanded] = useState(false); + + const handleAccordionChange = () => { + setExpanded(!expanded); + }; + const { fetchData, loading, response, error } = useFetch( "proxy/farmcalendar/api/v1/Farm/?format=json", { @@ -77,8 +89,22 @@ const FarmsPage = () => { const handleRowClick = (farm: FarmRow) => { navigate(`../farm/${farm.id.split(":")[3]}`); }; + + const onAddNewFarm = () => { + fetchData(); + setExpanded(false); + }; + return ( - <> + + + }> + Add new farm + + + + + {loading && } { !loading && !error && @@ -90,7 +116,7 @@ const FarmsPage = () => { open={snackbarState.open} onClose={closeSnackbar} /> - + ) } diff --git a/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx new file mode 100644 index 0000000..31254a5 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.tsx @@ -0,0 +1,135 @@ +import { Box, Button, Stack, TextField } from "@mui/material"; +import { useEffect, useState } from "react"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { CropTypeModel } from "@models/CropType"; +import { AddCropTypeProps } from "./AddCropType.types"; + +const REQUIRED_KEYS = new Set(['crop', 'kc_init', 'kc_mid', 'kc_end']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const KC_MIN = 0; +const KC_MAX = 2; + +const inKcRange = (v: number | null) => + v != null && !Number.isNaN(v) && v > KC_MIN && v < KC_MAX; + +type CropCreate = Omit; + +const emptyForm: CropCreate = { + crop: '', + kc_init: NaN, + kc_mid: NaN, + kc_end: NaN, +}; + +const AddCropType: React.FC = ({ onAction, existingNames = [] }) => { + const [formData, setFormData] = useState({ ...emptyForm }); + + const { fetchData, response, error, loading } = useFetch( + 'proxy/irrigation/api/v1/eto/crop-types/', + { method: 'POST' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Crop type added successfully'); + setFormData({ ...emptyForm }); + onAction && onAction(); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'An error occurred'); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: name === 'crop' ? value : (value === '' ? NaN : parseFloat(value)), + })); + }; + + const isDuplicate = existingNames.includes(formData.crop.trim()); + + const fieldEmpty = (key: string) => { + switch (key) { + case 'crop': return !formData.crop.trim(); + case 'kc_init': return !inKcRange(formData.kc_init); + case 'kc_mid': return !inKcRange(formData.kc_mid); + case 'kc_end': return !inKcRange(formData.kc_end); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)) || isDuplicate; + + const handlePost = () => { + fetchData({ body: { ...formData, crop: formData.crop.trim() } }); + }; + + return ( + + + + + + + + + + + + + + + ); +}; + +export default AddCropType; diff --git a/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts new file mode 100644 index 0000000..3077cbe --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddCropType/AddCropType.types.ts @@ -0,0 +1,4 @@ +export interface AddCropTypeProps { + onAction?: () => void; + existingNames?: string[]; +} diff --git a/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx new file mode 100644 index 0000000..0127b98 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.tsx @@ -0,0 +1,122 @@ +import { Box, Button, Stack, TextField } from "@mui/material"; +import { useEffect, useState } from "react"; +import AddIcon from '@mui/icons-material/Add'; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { SoilTypeModel } from "@models/SoilType"; +import { AddSoilTypeProps } from "./AddSoilType.types"; + +const REQUIRED_KEYS = new Set(['soil_type', 'field_capacity', 'wilting_point']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const inUnitRange = (v: number | null) => + v != null && !Number.isNaN(v) && v > 0 && v < 1; + +type SoilCreate = Omit; + +const emptyForm: SoilCreate = { + soil_type: '', + field_capacity: NaN, + wilting_point: NaN, +}; + +const AddSoilType: React.FC = ({ onAction, existingNames = [] }) => { + const [formData, setFormData] = useState({ ...emptyForm }); + + const { fetchData, response, error, loading } = useFetch( + 'proxy/irrigation/api/v1/dataset/soil-types/', + { method: 'POST' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + useEffect(() => { + if (response) { + showSnackbar('success', 'Soil type added successfully'); + setFormData({ ...emptyForm }); + onAction && onAction(); + } + }, [response]); + + useEffect(() => { + if (error) showSnackbar('error', 'An error occurred'); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: name === 'soil_type' ? value : (value === '' ? NaN : parseFloat(value)), + })); + }; + + const isDuplicate = existingNames.includes(formData.soil_type.trim()); + + const fieldEmpty = (key: string) => { + switch (key) { + case 'soil_type': return !formData.soil_type.trim(); + case 'field_capacity': return !inUnitRange(formData.field_capacity); + case 'wilting_point': return !inUnitRange(formData.wilting_point); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)) || isDuplicate; + + const handlePost = () => { + fetchData({ body: { ...formData, soil_type: formData.soil_type.trim() } }); + }; + + return ( + + + + + + + + + + + + + + ); +}; + +export default AddSoilType; diff --git a/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts new file mode 100644 index 0000000..eacdfc5 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/AddSoilType/AddSoilType.types.ts @@ -0,0 +1,4 @@ +export interface AddSoilTypeProps { + onAction?: () => void; + existingNames?: string[]; +} diff --git a/src/pages/dashboard/services/Irrigation/CropTypes.tsx b/src/pages/dashboard/services/Irrigation/CropTypes.tsx new file mode 100644 index 0000000..be6f1e8 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/CropTypes.tsx @@ -0,0 +1,152 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { Accordion, AccordionDetails, AccordionSummary, Box, IconButton, Skeleton, Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import AddCropType from "./AddCropType/AddCropType"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import useDialog from "@hooks/useDialog"; +import { CropTypeModel } from "@models/CropType"; + +interface CropRow { + id: string; + crop: string; + kc_init: number; + kc_mid: number; + kc_end: number; + actions: string; +} + +const CropTypesPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [rows, setRows] = useState([]); + const [pendingDelete, setPendingDelete] = useState<{ id: string; label: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const { fetchData, loading, response, error } = useFetch( + 'proxy/irrigation/api/v1/eto/option-types/', + { method: 'GET', noCache: true } + ); + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( + '', + { method: 'DELETE' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading crop types'); + }, [error]); + + useEffect(() => { + if (deleteError) showSnackbar('error', 'Error deleting crop type'); + }, [deleteError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Crop type deleted'); + fetchData(); + } + }, [deleteResponse]); + + useEffect(() => { + if (response) { + setRows(response.map(c => ({ + id: c.id, + crop: c.crop, + kc_init: c.kc_init, + kc_mid: c.kc_mid, + kc_end: c.kc_end, + actions: '', + }))); + } + }, [response]); + + const navigate = useNavigate(); + + const headCells: readonly HeadCell[] = [ + { id: 'crop', numeric: false, label: 'Crop name' }, + { id: 'kc_init', numeric: true, label: 'Kc init' }, + { id: 'kc_mid', numeric: true, label: 'Kc mid' }, + { id: 'kc_end', numeric: true, label: 'Kc end' }, + { id: 'actions', numeric: false, label: 'Actions', disableSort: true, renderCell: (row) => ( + + { e.stopPropagation(); navigate(`../crop-types/${row.id}`); }} + > + + + { + e.stopPropagation(); + setPendingDelete({ id: row.id, label: row.crop }); + showDialog({ + title: `Delete crop type "${row.crop}"?`, + variant: 'yes-no', + children: <>, + }); + }} + > + + + + ) }, + ]; + + const handleDelete = async () => { + if (!pendingDelete) return; + await deleteFetchData({ url: `proxy/irrigation/api/v1/eto/crop-types/${pendingDelete.id}/` }); + setPendingDelete(null); + }; + + const onAddNew = () => { + fetchData(); + setExpanded(false); + }; + + return ( + + setExpanded(v => !v)}> + }> + Add new crop type + + + r.crop)} /> + + + {loading && } + {!(loading) && !error && ( + + )} + + + + ); +}; + +export default CropTypesPage; diff --git a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx index 142632d..a7cefb0 100644 --- a/src/pages/dashboard/services/Irrigation/EToCalculator.tsx +++ b/src/pages/dashboard/services/Irrigation/EToCalculator.tsx @@ -14,6 +14,8 @@ import { LineChart } from "@mui/x-charts"; import CalculateIcon from '@mui/icons-material/Calculate'; import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; import { useSession } from "@contexts/SessionContext"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { CropTypeModel } from "@models/CropType"; const EToCalculatorPage = () => { @@ -24,13 +26,16 @@ const EToCalculatorPage = () => { const [fromDate, setFromDate] = useState(dayjs().subtract(14, 'days')); const [toDate, setToDate] = useState(dayjs()); const [calculations, setSetCalculations] = useState(undefined); + const [selectedCropType, setSelectedCropType] = useState(''); + const [selectedCropStage, setSelectedCropStage] = useState(''); + const [chartData, setChartData] = useState< { x: Date; y: number; }[] | undefined>(undefined); const { fetchData, response, error, loading } = useFetch( - `proxy/irrigation/api/v1/eto/calculate-gk/?parcel_id=${session?.farm_parcel?.["@id"].split(":")[3]}&from_date=${fromDate?.format('YYYY-MM-DD')}&to_date=${toDate?.format('YYYY-MM-DD')}&formatting=JSON`, + `proxy/irrigation/api/v1/eto/calculate-gk/?parcel_id=${session?.farm_parcel?.["@id"].split(":")[3]}&from_date=${fromDate?.format('YYYY-MM-DD')}&to_date=${toDate?.format('YYYY-MM-DD')}&formatting=JSON${selectedCropType && '&crop=' + selectedCropType}${selectedCropStage && '&stage=' + selectedCropStage}`, // `proxy/irrigation/api/v1/eto/get-calculations/${selectedLocation}/from/${fromDate?.format('YYYY-MM-DD')}/to/${toDate?.format('YYYY-MM-DD')}/?response_format=JSON-LD/`, { method: 'GET', @@ -64,7 +69,7 @@ const EToCalculatorPage = () => { })); setChartData(chartData); } - }, [calculations]) + }, [calculations]) return ( <> @@ -73,17 +78,30 @@ const EToCalculatorPage = () => { - Select a location and a time frame to view its ETo calculation. + Select a location and a time frame to view its ETo calculation. Additionally select the crop type and growth stage for finer tuning. - {/* - endpoint='proxy/irrigation/api/v1/location/' - label='Locations' - transformResponse={response => response.locations} - selectedValue={selectedLocation} - setSelectedValue={setSelectedLocation} - getOptionLabel={item => `(${item.longitude}, ${item.latitude})`} - getOptionValue={item => item.id}> - */} + + endpoint='proxy/irrigation/api/v1/eto/option-types/' + method="GET" + label='Crop type' + selectedValue={selectedCropType} + setSelectedValue={setSelectedCropType} + getOptionLabel={item => item.crop} + getOptionValue={item => item.id} + /> + + endpoint='' + data={[ + { value: 'KC_INIT', label: 'Init' }, + { value: 'KC_MID', label: 'Mid' }, + { value: 'KC_END', label: 'End' }, + ]} + label='Stages' + selectedValue={selectedCropStage} + setSelectedValue={setSelectedCropStage} + getOptionLabel={item => item.label} + getOptionValue={item => item.value} + /> { loadingPosition="start" onClick={handleSubmit} // disabled={!selectedLocation || !fromDate || !toDate} - disabled={!fromDate || !toDate} + disabled={!fromDate || !toDate || Boolean(selectedCropType && !selectedCropStage) || Boolean(selectedCropStage && !selectedCropType)} variant="contained"> Get calculations diff --git a/src/pages/dashboard/services/Irrigation/EditCropType.tsx b/src/pages/dashboard/services/Irrigation/EditCropType.tsx new file mode 100644 index 0000000..e37c652 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/EditCropType.tsx @@ -0,0 +1,159 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { CropTypeModel } from "@models/CropType"; +import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import SaveIcon from '@mui/icons-material/Save'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; + +const REQUIRED_KEYS = new Set(['crop', 'kc_init', 'kc_mid', 'kc_end']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const KC_MIN = 0; +const KC_MAX = 2; +const inKcRange = (v: number | null | undefined) => + v != null && !Number.isNaN(v) && v > KC_MIN && v < KC_MAX; + +const EditCropTypePage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { id } = useParams(); + const cropId = id ?? ''; + + const [formData, setFormData] = useState(); + + const { fetchData, loading, response, error } = useFetch( + `proxy/irrigation/api/v1/eto/crop-types/${cropId}/`, + { method: 'GET', noCache: true } + ); + + const { fetchData: putFetchData, response: putResponse, error: putError, loading: putLoading } = useFetch( + `proxy/irrigation/api/v1/eto/crop-types/${cropId}/`, + { method: 'PUT' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading crop type'); + }, [error]); + + useEffect(() => { + if (putError) showSnackbar('error', 'Error saving crop type'); + }, [putError]); + + useEffect(() => { + if (response) setFormData(response); + }, [response]); + + useEffect(() => { + if (putResponse) navigate('/crop-types'); + }, [putResponse]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => { + if (!prev) return undefined; + return { + ...prev, + [name]: name === 'crop' ? value : (value === '' ? NaN : parseFloat(value)), + }; + }); + }; + + const fieldEmpty = (key: string) => { + if (!formData) return true; + switch (key) { + case 'crop': return !formData.crop.trim() || formData.crop.trim().length > 64; + case 'kc_init': return !inKcRange(formData.kc_init); + case 'kc_mid': return !inKcRange(formData.kc_mid); + case 'kc_end': return !inKcRange(formData.kc_end); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); + + const handleSave = () => { + if (!formData) return; + const { id: _id, ...rest } = formData; + putFetchData({ body: { ...rest, crop: rest.crop.trim() } }); + }; + + return ( + <> + {loading && } + {!(loading || error) && formData && ( + + {formData.crop} + + + + 64 ? 'Max 64 characters' : ''} + /> + + + + + + + + + + + + + )} + + + ); +}; + +export default EditCropTypePage; diff --git a/src/pages/dashboard/services/Irrigation/EditSoilType.tsx b/src/pages/dashboard/services/Irrigation/EditSoilType.tsx new file mode 100644 index 0000000..8b6c6a0 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/EditSoilType.tsx @@ -0,0 +1,148 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { SoilTypeModel } from "@models/SoilType"; +import { Box, Button, Card, CardContent, Skeleton, Stack, TextField, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext, useParams } from "react-router-dom"; +import SaveIcon from '@mui/icons-material/Save'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; + +const REQUIRED_KEYS = new Set(['soil_type', 'field_capacity', 'wilting_point']); +const isReq = (k: string) => REQUIRED_KEYS.has(k); + +const inUnitRange = (v: number | null | undefined) => + v != null && !Number.isNaN(v) && v > 0 && v < 1; + +const EditSoilTypePage = () => { + const { actions } = useOutletContext(); + const canEdit = actions.includes('edit'); + + const { id } = useParams(); + const soilId = id ?? ''; + + const [formData, setFormData] = useState(); + + const { fetchData, loading, response, error } = useFetch( + `proxy/irrigation/api/v1/dataset/soil-types/${soilId}/`, + { method: 'GET', noCache: true } + ); + + const { fetchData: putFetchData, response: putResponse, error: putError, loading: putLoading } = useFetch( + `proxy/irrigation/api/v1/dataset/soil-types/${soilId}/`, + { method: 'PUT' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading soil type'); + }, [error]); + + useEffect(() => { + if (putError) showSnackbar('error', 'Error saving soil type'); + }, [putError]); + + useEffect(() => { + if (response) setFormData(response); + }, [response]); + + useEffect(() => { + if (putResponse) navigate('/soil-types'); + }, [putResponse]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => { + if (!prev) return undefined; + return { + ...prev, + [name]: name === 'soil_type' ? value : (value === '' ? NaN : parseFloat(value)), + }; + }); + }; + + const fieldEmpty = (key: string) => { + if (!formData) return true; + switch (key) { + case 'soil_type': return !formData.soil_type.trim() || formData.soil_type.trim().length > 64; + case 'field_capacity': return !inUnitRange(formData.field_capacity); + case 'wilting_point': return !inUnitRange(formData.wilting_point); + default: return false; + } + }; + + const isFormInvalid = Array.from(REQUIRED_KEYS).some(k => fieldEmpty(k)); + + const handleSave = () => { + if (!formData) return; + const { id: _id, ...rest } = formData; + putFetchData({ body: { ...rest, soil_type: rest.soil_type.trim() } }); + }; + + return ( + <> + {loading && } + {!(loading || error) && formData && ( + + {formData.soil_type} + + + + 64 ? 'Max 64 characters' : ''} + /> + + + + + + + + + + + + )} + + + ); +}; + +export default EditSoilTypePage; diff --git a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx index 2c5a29c..9cb4b65 100644 --- a/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx +++ b/src/pages/dashboard/services/Irrigation/SoilMoistureAnalysis.tsx @@ -1,127 +1,316 @@ +import { useMemo, useState, useEffect } from 'react'; +import { + Chart, + Series, + Title, + Tooltip +} from '@highcharts/react'; +import * as Highcharts from 'highcharts'; + import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { SoilTypeModel } from "@models/SoilType"; import useFetch from "@hooks/useFetch"; -import { DatasetRow, SoilMoistureResponseJSON } from "@models/SoilMoisture"; -import { Box, Card, CardContent, Skeleton, Typography, /* useTheme */ } from "@mui/material"; -// import { LineChart } from "@mui/x-charts"; -// import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { DatasetResponse, DatasetRow } from "@models/SoilMoisture"; +import { Box, Button, Card, CardContent, Skeleton, Typography } from "@mui/material"; +import dayjs from "dayjs"; +import utc from 'dayjs/plugin/utc'; + +import DeleteIcon from '@mui/icons-material/Delete'; +import useDialog from '@hooks/useDialog'; +import GenericDialog from '@components/shared/GenericDialog/GenericDialog'; +import GenericSnackbar from '@components/shared/GenericSnackbar/GenericSnackbar'; +import useSnackbar from '@hooks/useSnackbar'; +import { ServiceContextType } from '@layouts/services/IrrigationManagementLayout'; +import { useOutletContext } from 'react-router-dom'; -// const dateFormatter = Intl.DateTimeFormat(undefined, { -// month: '2-digit', -// day: '2-digit', -// }); +dayjs.extend(utc); const SoilMoistureAnalysisPage = () => { - // const theme = useTheme(); + const { actions } = useOutletContext(); + const canDelete = actions.includes('delete'); const [selectedDataset, setSelectedDataset] = useState(''); + const [selectedSoil, setSelectedSoil] = useState(''); - // const [formattedData, setFormattedData] = useState([]); - - const { fetchData: analysisFetchData, loading: analysisLoading, response: analysisResponse } = useFetch( - `proxy/irrigation/api/v1/dataset/${selectedDataset}/analysis/?formatting=JSON`, + const { fetchData: datapointsFetchData, loading: datapointsLoading, response: datapointsResponse } = useFetch( + `proxy/irrigation/api/v1/dataset/${selectedDataset}/irrigation-datapoints/?formatting=JSON${selectedSoil && '&soil=' + selectedSoil}`, { method: 'GET' } ) - const { fetchData: datasetFetchData, loading: datasetLoading, response: datasetResponse } = useFetch( - `proxy/irrigation/api/v1/dataset/${selectedDataset}/?formatting=JSON`, + useEffect(() => { + if (selectedDataset) { + datapointsFetchData(); + } + }, [selectedDataset]) + + useEffect(() => { + if (selectedSoil) { + if (selectedDataset) { + datapointsFetchData(); + } + } + }, [selectedSoil]) + + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + + const { dialogProps, showDialog } = useDialog(); + + const handleCloseDialog = () => { + dialogProps.onClose(); + }; + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( // TODO: add loading and add loading handling in generic yes-no dialog + `proxy/irrigation/api/v1/dataset/${selectedDataset}/`, { - method: 'GET' + method: 'DELETE' } - ) + ); + + const handleDelete = () => { + console.log("dataset to delete:", selectedDataset); + deleteFetchData(); + }; + + const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { - if (selectedDataset && !analysisLoading) { - analysisFetchData(); - datasetFetchData(); + if (deleteResponse) { + setSelectedDataset(''); + setRefreshKey(prev => prev + 1); + showSnackbar('success', "Successfully deleted dataset"); } - }, [selectedDataset]) + }, [deleteResponse]); - // useEffect(() => { - // if (datasetResponse) { - // const formattedData = datasetResponse?.slice(0,200); - // setFormattedData(formattedData); - // } - // }, [datasetResponse]) + useEffect(() => { + if (deleteError) { + showSnackbar('error', "An error occurred while deleting the dataset"); + } + }, [deleteError]); + + const { seriesData, highDoseDays, horizontalPlotLines } = useMemo(() => { + if (!datapointsResponse || datapointsResponse.data_points.length === 0) { + return { seriesData: [], highDoseDays: [], horizontalPlotLines: [] }; + } + + const { data_points, high_dose_irrigation_days, field_capacity, stress_level, wilting_point } = datapointsResponse; + + console.log(field_capacity, stress_level, wilting_point); + + const soilMoistureKeys = ['soil_moisture_10', 'soil_moisture_20', 'soil_moisture_30', 'soil_moisture_40', 'soil_moisture_50', 'soil_moisture_60']; + + const preparedSeriesData = soilMoistureKeys.map(key => { + const depth = key.split('_')[2]; + + const data = data_points + .map(d => { + const timestamp = dayjs.utc(d.date).valueOf(); + const value = d[key as keyof DatasetRow] as number; + return [timestamp, value] as [number, number]; + }) + .filter(([_ts, val]) => val !== 0) as [number, number][]; + + return { + name: `Soil Moisture ${depth}cm`, + data: data, + key: key, + }; + }); + + const thresholds = [ + { value: field_capacity * 100, label: 'Field capacity' }, + { value: stress_level * 100, label: 'Stress level' }, + { value: wilting_point * 100, label: 'Wilting point' } + ]; + + const preparedThresholdLines = thresholds.map((t) => { + const plotLine: Highcharts.YAxisPlotLinesOptions = { + value: t.value, + color: 'black', + dashStyle: 'Solid', + width: 2, + zIndex: 3, + label: { + text: t.label, + align: 'right', + x: -10, + style: { + fontSize: '10px', + fontWeight: 'bold' + }, + y: t.label === 'Stress level' ? 0 : (t.label === 'Field capacity' ? -10 : 10) + } + } + return plotLine; + }); + + console.log(preparedThresholdLines); + + const preparedhighDoseDays = high_dose_irrigation_days.map((dateStr) => { + const timestamp = dayjs.utc(dateStr).valueOf(); + + const plotLineProps: Highcharts.XAxisPlotLinesOptions = { + value: timestamp, + color: '#FF0000', + dashStyle: 'LongDash', + width: 1, + zIndex: -5, + }; + return plotLineProps; + }); + + return { seriesData: preparedSeriesData, highDoseDays: preparedhighDoseDays, horizontalPlotLines: preparedThresholdLines }; + + }, [datapointsResponse]); + + console.log(seriesData); + + const chartReady = !datapointsLoading && datapointsResponse && seriesData.length > 0; return ( - - - - - - Select a dataset to see its soil moisture analysis - - - endpoint='proxy/irrigation/api/v1/dataset/' - label='Datasets' - selectedValue={selectedDataset} - setSelectedValue={setSelectedDataset} - getOptionLabel={item => item} - getOptionValue={item => item}> - - - - {analysisLoading || datasetLoading && } - {!(analysisLoading || datasetLoading) && datasetResponse - && - - // dateFormatter.format(new Date(date)), - // label: 'Timeline', - // }, - // ]} - // yAxis={[ - // { - // label: 'Soil moisture depth (%)', - // }, - // ]} - // series={[ - // { dataKey: 'soil_moisture_10', label: 'Soil Moisture 10', showMark: false, color: theme.palette.primary.light }, - // { dataKey: 'soil_moisture_20', label: 'Soil Moisture 20', showMark: false, color: theme.palette.secondary.dark }, - // { dataKey: 'soil_moisture_30', label: 'Soil Moisture 30', showMark: false, color: theme.palette.secondary.main }, - // { dataKey: 'soil_moisture_40', label: 'Soil Moisture 40', showMark: false, color: theme.palette.secondary.light }, - // { dataKey: 'soil_moisture_50', label: 'Soil Moisture 50', showMark: false, color: theme.palette.primary.main }, - // { dataKey: 'soil_moisture_60', label: 'Soil Moisture 60', showMark: false, color: theme.palette.primary.dark }, - // ]} - // height={300} - // /> - } - - - - {!(analysisLoading || datasetLoading) && analysisResponse && - <> - - - {/* High dose irrigation event dates */} + <> + + + + - {/* Hardcoded */} - Irrigation events: 2 - estimated dates: 25/6/2025, 30/8/2025 - Precipitation events: 6 - estimated dates: 7/6/2025, 8/6/2025, 13/7/2025-25/7/2025 - High dose irrigation events: 1 - estimated dates: 25/6/2025 - Stress level events: 12 - estimated dates: 26/5/2025-6/6/2025, 8/7/2025 - {/* Hardcoded */} - {/* {analysisResponse.high_dose_irrigation_events_dates.map(d => { - return
{dayjs(d).format('dddd, D/MMM/YYYY')}
- })} */} + + Select a dataset to see its soil moisture analysis. Also select a soil type to dynamically adjust the thresholds + + + + key={refreshKey} + endpoint='proxy/irrigation/api/v1/dataset/' + label='Datasets' + selectedValue={selectedDataset} + setSelectedValue={setSelectedDataset} + getOptionLabel={item => item} + getOptionValue={item => item}> + + + + + endpoint='proxy/irrigation/api/v1/dataset/soil-types/' + method="GET" + label='Soil type' + selectedValue={selectedSoil} + setSelectedValue={setSelectedSoil} + getOptionLabel={item => item.soil_type} + getOptionValue={item => item.id} + />
-
-
- - } -
+ + {datapointsLoading && } + + {selectedDataset && chartReady && ( + + Soil Moisture Analysis and Irrigation Events + {seriesData.map(series => ( + + ))} + + + + )} + + {!datapointsLoading && !datapointsResponse && ( + + Please select a dataset to load soil moisture readings. + + )} + +
+
+ + {selectedDataset && chartReady && + <> + + + + + Thresholds: + + Field capacity: {Math.round(Number(horizontalPlotLines[0].value))}% + + + Stress level: {Math.round(Number(horizontalPlotLines[1].value))}% + + + Wilting point: {Math.round(Number(horizontalPlotLines[2].value))}% + + + + High dose irrigation days: {highDoseDays.length} + + Estimated dates:  + {highDoseDays.map((d, index) => { + return dayjs(d.value).format('dddd MMM D') + `${(index < highDoseDays.length - 1) ? ', ' : ''}` + })} + + + + + + + } + + + + ) } diff --git a/src/pages/dashboard/services/Irrigation/SoilTypes.tsx b/src/pages/dashboard/services/Irrigation/SoilTypes.tsx new file mode 100644 index 0000000..628f5a3 --- /dev/null +++ b/src/pages/dashboard/services/Irrigation/SoilTypes.tsx @@ -0,0 +1,149 @@ +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import useSnackbar from "@hooks/useSnackbar"; +import { Accordion, AccordionDetails, AccordionSummary, Box, IconButton, Skeleton, Stack, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import AddSoilType from "./AddSoilType/AddSoilType"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { ServiceContextType } from "@layouts/services/IrrigationManagementLayout"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import useDialog from "@hooks/useDialog"; +import { SoilTypeModel } from "@models/SoilType"; + +interface SoilRow { + id: string; + soil_type: string; + field_capacity: number; + wilting_point: number; + actions: string; +} + +const SoilTypesPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [rows, setRows] = useState([]); + const [pendingDelete, setPendingDelete] = useState<{ id: string; label: string } | null>(null); + const [expanded, setExpanded] = useState(false); + + const { fetchData, loading, response, error } = useFetch( + 'proxy/irrigation/api/v1/dataset/soil-types/', + { method: 'GET', noCache: true } + ); + + const { fetchData: deleteFetchData, response: deleteResponse, error: deleteError } = useFetch( + '', + { method: 'DELETE' } + ); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + useEffect(() => { fetchData(); }, []); + + useEffect(() => { + if (error) showSnackbar('error', 'Error loading soil types'); + }, [error]); + + useEffect(() => { + if (deleteError) showSnackbar('error', 'Error deleting soil type'); + }, [deleteError]); + + useEffect(() => { + if (deleteResponse) { + showSnackbar('success', 'Soil type deleted'); + fetchData(); + } + }, [deleteResponse]); + + useEffect(() => { + if (response) { + setRows(response.map(s => ({ + id: s.id, + soil_type: s.soil_type, + field_capacity: s.field_capacity, + wilting_point: s.wilting_point, + actions: '', + }))); + } + }, [response]); + + const navigate = useNavigate(); + + const headCells: readonly HeadCell[] = [ + { id: 'soil_type', numeric: false, label: 'Soil type' }, + { id: 'field_capacity', numeric: true, label: 'Field capacity' }, + { id: 'wilting_point', numeric: true, label: 'Wilting point' }, + { id: 'actions', numeric: false, label: 'Actions', disableSort: true, renderCell: (row) => ( + + { e.stopPropagation(); navigate(`../soil-types/${row.id}`); }} + > + + + { + e.stopPropagation(); + setPendingDelete({ id: row.id, label: row.soil_type }); + showDialog({ + title: `Delete soil type "${row.soil_type}"?`, + variant: 'yes-no', + children: <>, + }); + }} + > + + + + ) }, + ]; + + const handleDelete = async () => { + if (!pendingDelete) return; + await deleteFetchData({ url: `proxy/irrigation/api/v1/dataset/soil-types/${pendingDelete.id}/` }); + setPendingDelete(null); + }; + + const onAddNew = () => { + fetchData(); + setExpanded(false); + }; + + return ( + + setExpanded(v => !v)}> + }> + Add new soil type + + + r.soil_type)} /> + + + {loading && } + {!(loading) && !error && ( + + )} + + + + ); +}; + +export default SoilTypesPage; diff --git a/src/pages/dashboard/services/PestAndDisease/Diseases.tsx b/src/pages/dashboard/services/PestAndDisease/Diseases.tsx deleted file mode 100644 index 79c3a4b..0000000 --- a/src/pages/dashboard/services/PestAndDisease/Diseases.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; -import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; -import useFetch from "@hooks/useFetch"; -import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Skeleton, Typography } from "@mui/material"; -import { ChangeEvent, useEffect, useState } from "react"; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { DiseasesResponseModel } from "@models/Disease"; - -import FileUploadIcon from '@mui/icons-material/FileUpload'; -import PublishIcon from '@mui/icons-material/Publish'; -import useSnackbar from "@hooks/useSnackbar"; -import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; - -const DiseasesPage = () => { - const [diseases, setDiseases] = useState([]); - - const [selectedFile, setSelectedFile] = useState(null); - - const [expanded, setExpanded] = useState(false); - - const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); - - const { fetchData: getFetchData, response: getResponse, error: getError, loading: getLoading } = useFetch( - `proxy/pdm/api/v1/pest-model/`, - { - method: 'GET', - } - ); - - const { fetchData: uploadFetchData, response: uploadResponse, error: uploadError, loading: uploadLoading } = useFetch( - `proxy/pdm/api/v1/pest-model/upload-excel/`, - { - method: 'POST', - } - ); - - useEffect(() => { - getFetchData(); - }, []); - - useEffect(() => { - if (getResponse) { - const formattedDiseases = getResponse.pests.map((d) => { - return { - id: d.id, - name: d.name, - description: d.description, - geoAreasOfApplication: d.geo_areas_of_application - } - }) - setDiseases(formattedDiseases); - } - }, [getResponse]) - - useEffect(() => { - if (uploadResponse) { - getFetchData(); - showSnackbar('success', "Disease dataset added successfully!"); - setExpanded(false); - } - }, [uploadResponse]) - - useEffect(() => { - if (uploadError) { - showSnackbar('error', uploadError?.message.toString() ?? 'Error uploading file'); - } - }, [uploadError]) - - interface DiseaseRow { - id: string; - name: string; - description: string; - geoAreasOfApplication: string; - } - - const diseasesHeadCells: readonly HeadCell[] = [ - { id: 'name', numeric: false, label: 'Name' }, - { id: 'description', numeric: false, label: 'Description' }, - { id: 'geoAreasOfApplication', numeric: false, label: 'GEO areas of application' }, - ]; - - const handleAccordionChange = () => { - setExpanded(!expanded); - }; - - const handleFileChange = (event: ChangeEvent) => { - if (event.target.files && event.target.files.length > 0) { - console.log(event.target.files[0]); - - setSelectedFile(event.target.files[0]); - } - }; - - const handleSubmit = async () => { - if (!selectedFile) return; - const formData = new FormData(); - formData.append('excel_file', selectedFile); - await uploadFetchData({ body: formData }); - } - - return ( - <> - - - }> - Add new disease - - - - - Upload an excel file containing a dataset - - - The file needs to have .xlsx as its extension. - - - { - selectedFile ? - - File uploaded, please submit. - : - - Please upload a file to the platform. - - } - - - - - - - - - - - {getLoading && } - { - !getLoading && !getError && - - } - - - - ) -} - -export default DiseasesPage; \ No newline at end of file diff --git a/src/pages/dashboard/services/PestAndDisease/Pests.tsx b/src/pages/dashboard/services/PestAndDisease/Pests.tsx index d293097..39cfe33 100644 --- a/src/pages/dashboard/services/PestAndDisease/Pests.tsx +++ b/src/pages/dashboard/services/PestAndDisease/Pests.tsx @@ -8,8 +8,15 @@ import { PestModel, PestsResponseModel } from "@models/Pest"; import { Accordion, AccordionDetails, AccordionSummary, Box, Skeleton, Typography } from "@mui/material"; import { useEffect, useState } from "react"; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { useOutletContext } from "react-router-dom"; +import { ServiceContextType } from "@layouts/services/PestAndDiseaseLayout"; const PestsPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + const [pests, setPests] = useState([]); const [selectedPest, setSelectedPest] = useState(undefined); @@ -61,7 +68,7 @@ const PestsPage = () => { const pestsHeadCells: readonly HeadCell[] = [ { id: 'name', numeric: false, label: 'Name' }, { id: 'eppoCode', numeric: false, label: 'EPPO code' }, - { id: 'baseGDD', numeric: false, label: 'Base GDD' }, + { id: 'baseGDD', numeric: false, label: 'Base GDD (°C)' }, ]; const handleRowClick = (pest: PestRow) => { @@ -102,12 +109,12 @@ const PestsPage = () => { return ( <> - + }> Add new pest - - + + {getLoading && } @@ -117,7 +124,7 @@ const PestsPage = () => { } - + ) diff --git a/src/pages/dashboard/services/PestAndDisease/RiskForecast.tsx b/src/pages/dashboard/services/PestAndDisease/RiskForecast.tsx new file mode 100644 index 0000000..e134d92 --- /dev/null +++ b/src/pages/dashboard/services/PestAndDisease/RiskForecast.tsx @@ -0,0 +1,202 @@ +import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; +import ContentGuard from "@components/shared/ContentGuard/ContentGuard"; +import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; +import { useSession } from "@contexts/SessionContext"; +import useFetch from "@hooks/useFetch"; +import { Box, Button, Card, CardContent, FormControlLabel, Skeleton, Switch, TextField, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { ThreatModel } from "@models/ThreatModel"; +import { FuzzyRiskForecastResponse } from "@models/FuzzyRiskForecast"; +import { LineChart } from "@mui/x-charts"; + +const MIN_DAYS_AHEAD = 1; +const MAX_DAYS_AHEAD = 14; +const DEFAULT_DAYS_AHEAD = 7; + +interface ThreatSeries { + threatModelId: string; + label: string; + data: (number | null)[]; +} + +interface ChartData { + dates: string[]; + series: ThreatSeries[]; +} + +const RiskForecastPage = () => { + + const { session } = useSession(); + + const [selectedThreatModelIds, setSelectedThreatModelIds] = useState([]); + const [daysAhead, setDaysAhead] = useState(DEFAULT_DAYS_AHEAD); + const [scaleToMax, setScaleToMax] = useState(false); + + const [threatModels, setThreatModels] = useState([]); + const [chartData, setChartData] = useState(null); + + const parcelId = session?.farm_parcel?.["@id"].split(":")[3]; + + const { fetchData: fetchThreatModels, response: threatModelsResponse } = useFetch( + 'proxy/pdm/api/v1/threat-model/', + { method: 'GET' } + ); + + const { fetchData: fetchForecast, response: forecastResponse, error: forecastError, loading: forecastLoading } = useFetch( + 'proxy/pdm/api/v1/fuzzy-risk/fc/forecast/?format=json', + { method: 'POST' } + ); + + useEffect(() => { + fetchThreatModels(); + }, []); + + useEffect(() => { + if (Array.isArray(threatModelsResponse)) { + setThreatModels(threatModelsResponse); + } + }, [threatModelsResponse]); + + const handleDisplayForecast = () => { + if (!parcelId) return; + fetchForecast({ + body: { + parcel_id: parcelId, + days_ahead: daysAhead, + ...(selectedThreatModelIds.length > 0 ? { threat_model_ids: selectedThreatModelIds } : {}), + }, + }); + }; + + useEffect(() => { + if (!Array.isArray(forecastResponse)) return; + + const dateSet = new Set(); + const byThreatId = new Map>(); + const labelById = new Map(); + + forecastResponse.forEach(row => { + dateSet.add(row.date); + // Match row back to selected threat model by common_name (response has no id). + const matchingModel = threatModels.find(tm => + tm.common_name === row.common_name && tm.scientific_name === row.scientific_name + ); + const key = matchingModel?.id ?? `${row.scientific_name}::${row.common_name}`; + const label = row.common_name || row.scientific_name; + labelById.set(key, label); + if (!byThreatId.has(key)) byThreatId.set(key, new Map()); + byThreatId.get(key)!.set(row.date, row.risk_score); + }); + + const dates = Array.from(dateSet).sort(); + const series: ThreatSeries[] = Array.from(byThreatId.entries()).map(([id, dateMap]) => ({ + threatModelId: id, + label: labelById.get(id) ?? id, + data: dates.map(d => dateMap.has(d) ? dateMap.get(d)! : null), + })); + + setChartData({ dates, series }); + }, [forecastResponse, threatModels]); + + const handleDaysAheadChange = (raw: string) => { + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) { + setDaysAhead(MIN_DAYS_AHEAD); + return; + } + const clamped = Math.min(MAX_DAYS_AHEAD, Math.max(MIN_DAYS_AHEAD, parsed)); + setDaysAhead(clamped); + }; + + return ( + <> + + + + + endpoint='proxy/pdm/api/v1/threat-model/' + data={threatModels} + label='Threat models' + selectedValue={selectedThreatModelIds} + setSelectedValue={setSelectedThreatModelIds} + getOptionLabel={item => item.common_name || item.scientific_name} + getOptionValue={item => item.id} + multiple + /> + handleDaysAheadChange(e.target.value)} + slotProps={{ htmlInput: { min: MIN_DAYS_AHEAD, max: MAX_DAYS_AHEAD } }} + helperText={`Between ${MIN_DAYS_AHEAD} and ${MAX_DAYS_AHEAD} days`} + /> + + + + {forecastLoading && } + {!forecastLoading && !forecastError && chartData && chartData.series.length > 0 && (() => { + const dataMax = chartData.series.reduce((max, s) => { + const seriesMax = s.data.reduce((m, v) => v !== null && v > m ? v : m, 0); + return seriesMax > max ? seriesMax : max; + }, 0); + const yMax = scaleToMax ? Math.max(1, Math.ceil(dataMax)) : 100; + return ( + + + + Risk forecast + setScaleToMax(checked)} + /> + } + label="Scale to max value" + /> + + + ({ + data: s.data, + label: s.label, + connectNulls: true, + valueFormatter: (value) => value === null ? 'N/A' : `${value.toFixed(1)}`, + }))} + xAxis={[{ + scaleType: 'point', + label: 'Date', + data: chartData.dates, + }]} + yAxis={[{ + min: 0, + max: yMax, + label: 'Risk score', + }]} + height={400} + /> + + + + ); + })()} + {!forecastLoading && !forecastError && chartData && chartData.series.length === 0 && ( + No risk forecast data returned for the selected parameters. + )} + {!forecastLoading && forecastError && ( + {forecastError.message} + )} + + + + ); +}; + +export default RiskForecastPage; diff --git a/src/pages/dashboard/services/PestAndDisease/RiskIndex.tsx b/src/pages/dashboard/services/PestAndDisease/RiskIndex.tsx deleted file mode 100644 index a6d9aae..0000000 --- a/src/pages/dashboard/services/PestAndDisease/RiskIndex.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import ParcelSelectionModule from "@components/dashboard/ParcelSelectionModule/ParcelSelectionModule"; -import ContentGuard from "@components/shared/ContentGuard/ContentGuard"; -import DateRangeSelect from "@components/shared/DateRangeSelect/DateRangeSelect"; -import GenericSelect from "@components/shared/GenericSelect/GenericSelect"; -import { useSession } from "@contexts/SessionContext"; -import useFetch from "@hooks/useFetch"; -import { Box, Button, Card, CardContent, Skeleton, Typography } from "@mui/material"; -import dayjs, { Dayjs } from "dayjs"; -import { useEffect, useState } from "react"; -import { DiseaseModel, DiseasesResponseModel } from "@models/Disease"; -import { RiskIndexModel, RiskIndexStatus } from "@models/RiskIndex.jsonld"; -import { LineChart } from "@mui/x-charts"; -import { colors } from "@theme/colors"; - -interface DailyData { - time?: string[]; - riskIndex?: RiskIndexStatus[] -} - -interface FormattedRiskIndexData { - [day: string]: DailyData -} - -const riskLevelMapping = (label: string): number => { - switch (label) { - case 'low': - return 0; - case 'moderate': - return 1; - case 'high': - return 2; - default: - return 0; - } -}; - -const riskLevelLabels = ['low', 'medium', 'high']; - -const RiskIndexPage = () => { - - const { session } = useSession() - - const [fromDate, setFromDate] = useState(dayjs().subtract(9, 'days')); - const [toDate, setToDate] = useState(dayjs().subtract(2, 'days')); - const [selectedDisease, setSelectedDisease] = useState(''); - - const [loadingFormat, setLoadingFormat] = useState(false); - - const [formattedData, setFormattedData] = useState(null); - - - - const { fetchData, response, error, loading } = useFetch( - `proxy/pdm/api/v1/model/${selectedDisease}/risk-index/?parcel_id=${session?.farm_parcel?.["@id"].split(":")[3]}&from_date=${fromDate?.format('YYYY-MM-DD')}&to_date=${toDate?.format('YYYY-MM-DD')}`, - { - method: 'GET', - } - ); - - const handleDisplayGDD = () => { - fetchData(); - }; - - useEffect(() => { - if (response && Array.isArray(response["@graph"]?.[0]?.hasMember)) { - setLoadingFormat(true); - - const processedData = response["@graph"][0].hasMember.reduce((accumulator, currentItem) => { - const { phenomenonTime, hasSimpleResult } = currentItem; - const [date, timeString] = phenomenonTime.split('T'); - const time = timeString.replace('Z', ''); - - if (!accumulator[date]) { - accumulator[date] = { time: [], riskIndex: [] }; - } - - accumulator[date]?.time?.push(time); - accumulator[date]?.riskIndex?.push(hasSimpleResult); - - return accumulator; - }, {}); - - setFormattedData(processedData); - setLoadingFormat(false); - } - }, [response]); - - useEffect(() => { - if (formattedData) { - console.log(formattedData); - } - }, [formattedData]) - - return ( - <> - - - - - endpoint='proxy/pdm/api/v1/pest-model/' - label='Diseases' - transformResponse={response => response.pests} - selectedValue={selectedDisease} - setSelectedValue={setSelectedDisease} - getOptionLabel={item => item.name} - getOptionValue={item => item.id}> - - - - - {(loading || loadingFormat) && } - {!(loading || loadingFormat) && !error && formattedData && - - {Object.entries(formattedData).map(([date, dailyData]: [string, DailyData]) => { - const numericalRiskData = dailyData.riskIndex?.map( - (status) => riskLevelMapping(status) - ).filter(value => value !== undefined) as number[] || []; - return - - {dayjs(date).format('dddd, D/MMM/YYYY')} - - - value !== null ? riskLevelLabels[value] : 'N/A', - }, - ]} - xAxis={[{ - scaleType: 'point', - label: 'Time', - data: dailyData.time?.map(t => { - return dayjs(date + t).format('H') - }), - }]} - yAxis={[{ - min: 0, - max: riskLevelLabels.length - 1, - tickInterval: riskLevelLabels.map((_, index) => index), - valueFormatter: (value: number) => riskLevelLabels[value], - }]} - height={200} - colors={[colors.primary.main]} - /> - - - - })} - - } - - - - ) -} - -export default RiskIndexPage; \ No newline at end of file diff --git a/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx b/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx new file mode 100644 index 0000000..86d859a --- /dev/null +++ b/src/pages/dashboard/services/PestAndDisease/ThreatModels.tsx @@ -0,0 +1,234 @@ +import GenericSortableTable from "@components/shared/GenericSortableTable/GenericSortableTable"; +import { HeadCell } from "@components/shared/GenericSortableTable/GenericSortableTable.types"; +import useFetch from "@hooks/useFetch"; +import { Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Skeleton, Typography } from "@mui/material"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { ThreatModel } from "@models/ThreatModel"; +import { Crop } from "@models/Crop"; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import PublishIcon from '@mui/icons-material/Publish'; +import useSnackbar from "@hooks/useSnackbar"; +import GenericSnackbar from "@components/shared/GenericSnackbar/GenericSnackbar"; +import { ServiceContextType } from "@layouts/services/PestAndDiseaseLayout"; +import { useOutletContext } from "react-router-dom"; +import useDialog from "@hooks/useDialog"; +import GenericDialog from "@components/shared/GenericDialog/GenericDialog"; +import ThreatModelCRUDActions from "@components/dashboard/services/ThreatModelCRUDActions/ThreatModelCRUDActions"; + +interface ThreatModelRow { + id: string; + commonName: string; + scientificName: string; + crop: string; + label: string; +} + +const ThreatModelsPage = () => { + const { actions } = useOutletContext(); + const canAdd = actions.includes('add'); + const canEdit = actions.includes('edit'); + const canDelete = actions.includes('delete'); + + const [threatModels, setThreatModels] = useState([]); + const [crops, setCrops] = useState([]); + const [selectedThreatModel, setSelectedThreatModel] = useState(undefined); + + const [selectedFile, setSelectedFile] = useState(null); + const [expanded, setExpanded] = useState(false); + + const { snackbarState, showSnackbar, closeSnackbar } = useSnackbar(); + const { dialogProps, showDialog } = useDialog(); + + const { fetchData: getFetchData, response: getResponse, error: getError, loading: getLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/`, + { method: 'GET' } + ); + + const { fetchData: getCropsFetch, response: cropsResponse } = useFetch( + `proxy/pdm/api/v1/crop/`, + { method: 'GET' } + ); + + const { fetchData: uploadFetchData, response: uploadResponse, error: uploadError, loading: uploadLoading } = useFetch( + `proxy/pdm/api/v1/threat-model/import-excel/`, + { method: 'POST' } + ); + + useEffect(() => { + getFetchData(); + getCropsFetch(); + }, []); + + useEffect(() => { + if (Array.isArray(getResponse)) setThreatModels(getResponse); + }, [getResponse]); + + useEffect(() => { + if (Array.isArray(cropsResponse)) setCrops(cropsResponse); + }, [cropsResponse]); + + useEffect(() => { + if (uploadResponse) { + getFetchData(); + showSnackbar('success', 'Threat models imported successfully!'); + setExpanded(false); + setSelectedFile(null); + } + }, [uploadResponse]); + + useEffect(() => { + if (uploadError) { + showSnackbar('error', uploadError?.message?.toString() ?? 'Error uploading file'); + } + }, [uploadError]); + + const cropNameById = useMemo(() => { + const map = new Map(); + crops.forEach(c => map.set(c.id, c.name)); + return map; + }, [crops]); + + const rows: ThreatModelRow[] = useMemo(() => threatModels.map(tm => ({ + id: tm.id, + commonName: tm.common_name, + scientificName: tm.scientific_name, + crop: cropNameById.get(tm.crop_id) ?? '—', + label: tm.label ?? '', + })), [threatModels, cropNameById]); + + const headCells: readonly HeadCell[] = [ + { id: 'commonName', numeric: false, label: 'Common name' }, + { id: 'scientificName', numeric: false, label: 'Scientific name' }, + { id: 'crop', numeric: false, label: 'Crop' }, + { id: 'label', numeric: false, label: 'Label' }, + ]; + + const handleRowClick = (row: ThreatModelRow) => { + const tm = threatModels.find(t => t.id === row.id); + if (!tm) return; + setSelectedThreatModel(tm); + showDialog({ + title: `Details for ${tm.common_name || tm.scientific_name}`, + variant: 'empty', + children: <>, + }); + }; + + const handleCloseDialog = () => { + dialogProps.onClose(); + setSelectedThreatModel(undefined); + }; + + const handleAccordionChange = () => setExpanded(prev => !prev); + + const handleFileChange = (event: ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + setSelectedFile(event.target.files[0]); + } + }; + + const handleSubmit = async () => { + if (!selectedFile) return; + const formData = new FormData(); + formData.append('file', selectedFile); + await uploadFetchData({ body: formData }); + }; + + const onAfterEdit = () => { + getFetchData(); + handleCloseDialog(); + }; + + return ( + <> + + + }> + Add new threat model + + + + + Bulk import from Excel + + Upload an Excel file containing threat model definitions. + + + The file needs to have .xlsx as its extension. + + + {selectedFile ? 'File selected, please submit.' : 'Please select a file to import.'} + + + + + + + + + + + + Create a single threat model + { + getFetchData(); + setExpanded(false); + }} + canEdit={canAdd} + canDelete={canDelete} + /> + + + + + {getLoading && } + {!getLoading && !getError && ( + + )} + + + + + + + ); +}; + +export default ThreatModelsPage; diff --git a/src/utils/activityModelFactory.ts b/src/utils/activityModelFactory.ts new file mode 100644 index 0000000..45fbe87 --- /dev/null +++ b/src/utils/activityModelFactory.ts @@ -0,0 +1,439 @@ +import { + BaseActivityModel, + AddRawMaterialOperationModel, + AnimalActivityModel, + AnimalLactatingActivityModel, + CompostOperationModel, + CompostTurningOperationModel, + CropGrowthStageObservationModel, + CropProtectionOperationModel, + CropStressIndicatorObservationModel, + DiseaseDetectionModel, + FertilizationOperationModel, + GenericActivityModel, + GenericAlertModel, + GenericObservationModel, + IrrigationOperationModel, + SprayingRecommendationModel, + VigorEstimationModel, + YieldPredictionModel +} from '@models/FarmCalendarActivities'; +import dayjs from 'dayjs'; + +const getEmptyBaseActivity = (id: string): BaseActivityModel => ({ + "@id": "", + "@type": "", + activityType: { + "@id": `urn:farmcalendar:FarmCalendarActivityType:${id}`, + "@type": "FarmCalendarActivityType" + }, + title: "", + details: "" +}); + + +const getEmptyAddRawMaterialOperation = (id: string): AddRawMaterialOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "AddRawMaterialOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + operatedOn: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [], + hasCompostMaterial: [] +}); + +const getEmptyCompostOperation = (id: string): CompostOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "CompostOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + responsibleAgent: null, + usesAgriculturalMachinery: [], + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + isOperatedOn: { + "@id": "", + "@type": "CompostPile" + }, + hasNestedOperation: [], + hasMeasurement: [] +}); + +const getEmptyCompostTurningOperation = (id: string): CompostTurningOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "CompostTurningOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + operatedOn: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [] +}); + +const getEmptyCropGrowthStageObservation = (id: string): CropGrowthStageObservationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "CropGrowthStageObservation", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasAgriCrop: { + "@id": "", + "@type": "FarmCrop" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyCropProtectionOperation = (id: string): CropProtectionOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "CropProtectionOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + usesPesticide: { + "@id": "", + "@type": "Pesticide" + }, + operatedOn: { + "@id": "", + "@type": "Parcel" + } +}); + +const getEmptyCropStressIndicatorObservation = (id: string): CropStressIndicatorObservationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "CropStressIndicatorObservation", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasAgriCrop: { + "@id": "", + "@type": "FarmCrop" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyDiseaseDetection = (id: string): DiseaseDetectionModel => ({ + ...getEmptyBaseActivity(id), + "@type": "DiseaseDetection", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyFertilizationOperation = (id: string): FertilizationOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "FertilizationOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + hasApplicationMethod: "", + usesFertilizer: { + "@id": "", + "@type": "Fertilizer" + }, + operatedOn: { + "@id": "", + "@type": "Parcel" + } +}); + +const getEmptyGenericActivity = (id: string): GenericActivityModel => ({ + ...getEmptyBaseActivity(id), + "@type": "FarmCalendarActivity", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + responsibleAgent: null, + usesAgriculturalMachinery: [] +}); + +const getEmptyGenericAlert = (id: string): GenericAlertModel => ({ + ...getEmptyBaseActivity(id), + "@type": "Alert", + severity: "", + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + validFrom: dayjs().toISOString(), + validTo: dayjs().toISOString(), + dateIssued: "", + quantityValue: {}, + relatedObservation: null +}); + +const getEmptyGenericObservation = (id: string): GenericObservationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "Observation", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyIrrigationOperation = (id: string): IrrigationOperationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "IrrigationOperation", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasAppliedAmount: { + "@id": "", + "@type": "", + unit: "", + numericValue: 0 + }, + usesIrrigationSystem: "", + operatedOn: { + "@id": "", + "@type": "Parcel" + } +}); + +const getEmptySprayingRecommendation = (id: string): SprayingRecommendationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "SprayingRecommendation", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "", + usesPesticide: { + "@id": "", + "@type": "Pesticide" + } +}); + +const getEmptyVigorEstimation = (id: string): VigorEstimationModel => ({ + ...getEmptyBaseActivity(id), + "@type": "VigorEstimation", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + +const getEmptyYieldPrediction = (id: string): YieldPredictionModel => ({ + ...getEmptyBaseActivity(id), + "@type": "YieldPrediction", + phenomenonTime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + madeBySensor: { + "@id": "", + "@type": "", + name: "" + }, + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + isPartOfActivity: null, + hasArea: "", + hasResult: { + "@id": "", + "@type": "", + unit: "", + hasValue: "" + }, + observedProperty: "" +}); + + +const emptyMeasurement = () => ({ + "@id": "", + "@type": "", + unit: "", + hasValue: "", +}); + +const getEmptyAnimalActivity = (id: string): AnimalActivityModel => ({ + ...getEmptyBaseActivity(id), + "@type": "AnimalActivity", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + hasAnimal: { + "@id": "", + "@type": "Animal" + }, + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, +}); + +const getEmptyAnimalLactatingActivity = (id: string): AnimalLactatingActivityModel => ({ + ...getEmptyBaseActivity(id), + "@type": "AnimalLactatingActivity", + hasStartDatetime: dayjs().toISOString(), + hasEndDatetime: dayjs().toISOString(), + hasAgriParcel: { + "@id": "", + "@type": "Parcel" + }, + hasAnimal: { + "@id": "", + "@type": "Animal" + }, + responsibleAgent: null, + usesAgriculturalMachinery: [], + isPartOfActivity: null, + hasDaysInMilk: "", + hasLactationNumber: "", + hasControl: "", + hasTotalMilkYield: emptyMeasurement(), + hasMilkYield: emptyMeasurement(), + hasRCS: emptyMeasurement(), + hasUrea: emptyMeasurement(), + hasFat: emptyMeasurement(), + hasProtein: emptyMeasurement(), + hasDryMatter: emptyMeasurement(), +}); + +export const activityModelFactory: Record BaseActivityModel> = { + "/api/v1/YieldPrediction/": getEmptyYieldPrediction, + "/api/v1/VigorEstimation/": getEmptyVigorEstimation, + "/api/v1/Observations/": getEmptyGenericObservation, + "/api/v1/SprayingRecommendation/": getEmptySprayingRecommendation, + "/api/v1/CropProtectionOperations/": getEmptyCropProtectionOperation, + "/api/v1/IrrigationOperations/": getEmptyIrrigationOperation, + "/api/v1/FertilizationOperations/": getEmptyFertilizationOperation, + "/api/v1/DiseaseDetection/": getEmptyDiseaseDetection, + "/api/v1/Alerts/": getEmptyGenericAlert, + "/api/v1/FarmCalendarActivities/": getEmptyGenericActivity, + "/api/v1/CropStressIndicatorObservations/": getEmptyCropStressIndicatorObservation, + "/api/v1/CropGrowthStageObservations/": getEmptyCropGrowthStageObservation, + "/api/v1/CompostTurningOperations/": getEmptyCompostTurningOperation, + "/api/v1/CompostOperations/": getEmptyCompostOperation, + "/api/v1/AddRawMaterialOperations/": getEmptyAddRawMaterialOperation, + "/api/v1/AnimalActivities/": getEmptyAnimalActivity, + "/api/v1/AnimalLactatingActivities/": getEmptyAnimalLactatingActivity, +}; \ No newline at end of file diff --git a/src/utils/prefetchOffline.ts b/src/utils/prefetchOffline.ts new file mode 100644 index 0000000..eb2a8f8 --- /dev/null +++ b/src/utils/prefetchOffline.ts @@ -0,0 +1,131 @@ +import dayjs from "dayjs"; + +const GLOBAL_ENDPOINTS = [ + 'proxy/farmcalendar/api/v1/FarmCalendarActivityTypes/?format=json', + 'proxy/farmcalendar/api/v1/Farm/?format=json', + 'proxy/farmcalendar/api/v1/FarmParcels/?format=json', + 'proxy/farmcalendar/api/v1/FarmAnimals/?format=json', + 'proxy/farmcalendar/api/v1/AgriculturalMachines/?format=json', + 'proxy/farmcalendar/api/v1/Pesticides/?format=json', + 'proxy/farmcalendar/api/v1/Fertilizers/?format=json', + 'proxy/farmcalendar/api/v1/Observations/?format=json', + 'proxy/pdm/api/v1/crop/', + 'proxy/pdm/api/v1/disease/', + 'proxy/pdm/api/v1/threat-model/', + 'proxy/irrigation/api/v1/dataset/soil-types/', + 'proxy/irrigation/api/v1/eto/option-types/', + 'me/', +]; + +export interface PrefetchParcel { + id: string; + lat: number | null; + lon: number | null; +} + +export const buildPrefetchURLs = ( + parcels: PrefetchParcel[], + from: dayjs.Dayjs, + to: dayjs.Dayjs, +): string[] => { + const urls = [...GLOBAL_ENDPOINTS]; + + const monthRanges: { from: string; to: string }[] = []; + let cursor = from.startOf('month'); + const end = to.endOf('month'); + while (cursor.isBefore(end) || cursor.isSame(end, 'month')) { + monthRanges.push({ + from: cursor.startOf('month').format('YYYY-MM-DD'), + to: cursor.endOf('month').format('YYYY-MM-DD'), + }); + cursor = cursor.add(1, 'month'); + } + + for (const p of parcels) { + for (const range of monthRanges) { + urls.push( + `proxy/farmcalendar/api/v1/FarmCalendarActivities/?parcel=${p.id}&format=json&fromDate=${range.from}&toDate=${range.to}`, + ); + } + if (p.lat !== null && p.lon !== null) { + urls.push( + `proxy/weather_data/api/data/forecast5/?lat=${p.lat}&lon=${p.lon}`, + `proxy/weather_data/api/data/spray-forecast/?lat=${p.lat}&lon=${p.lon}`, + `proxy/weather_data/api/data/flight-forecast5/?lat=${p.lat}&lon=${p.lon}`, + ); + } + } + return urls; +}; + +const getApiUrl = (): string => + (window as any).env?.VITE_API_URL ? (window as any).env.VITE_API_URL : import.meta.env.VITE_API_URL; + +interface PrefetchResult { + url: string; + ok: boolean; + status: number; +} + +const fetchOne = async (url: string, token: string): Promise => { + const apiUrl = getApiUrl(); + try { + const res = await fetch(apiUrl + url, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + }); + return { url, ok: res.ok, status: res.status }; + } catch { + return { url, ok: false, status: 0 }; + } +}; + +export const runPrefetch = async ( + urls: string[], + token: string, + concurrency: number, + onProgress: (done: number, total: number, lastFailure: PrefetchResult | null) => void, +): Promise => { + const results: PrefetchResult[] = []; + let cursor = 0; + + const worker = async () => { + while (cursor < urls.length) { + const idx = cursor++; + const result = await fetchOne(urls[idx], token); + results.push(result); + onProgress(results.length, urls.length, result.ok ? null : result); + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker)); + return results; +}; + +export const statusText = (status: number): string => { + if (status === 0) return 'Network error (no response)'; + switch (status) { + case 400: return 'Bad request'; + case 401: return 'Unauthorized'; + case 403: return 'Forbidden'; + case 404: return 'Not found'; + case 408: return 'Request timeout'; + case 429: return 'Too many requests'; + case 500: return 'Internal server error'; + case 502: return 'Bad gateway'; + case 503: return 'Service unavailable'; + case 504: return 'Gateway timeout'; + } + if (status >= 400 && status < 500) return `Client error (${status})`; + if (status >= 500 && status < 600) return `Server error (${status})`; + return `Status ${status}`; +}; + +export const DEFAULT_FROM_OFFSET_DAYS = 30; +export const DEFAULT_TO_OFFSET_DAYS = 300; +export const MAX_RANGE_DAYS = 365; + +export const defaultDateRange = () => ({ + from: dayjs().subtract(DEFAULT_FROM_OFFSET_DAYS, 'day'), + to: dayjs().add(DEFAULT_TO_OFFSET_DAYS, 'day'), +}); diff --git a/src/utils/pwaCache.ts b/src/utils/pwaCache.ts new file mode 100644 index 0000000..d23f003 --- /dev/null +++ b/src/utils/pwaCache.ts @@ -0,0 +1,9 @@ +export const clearUserCaches = async (): Promise => { + if (!('caches' in window)) return; + const names = await caches.keys(); + await Promise.all( + names + .filter(n => n.startsWith('oa-user-')) + .map(n => caches.delete(n)) + ); +}; diff --git a/vite.config.ts b/vite.config.ts index 5ed36ec..6e8e9ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,10 @@ import tsconfigPaths from 'vite-tsconfig-paths'; // https://vitejs.dev/config/ export default defineConfig({ + preview: { + // allow cloudflared/ngrok tunnel hosts for mobile PWA testing + allowedHosts: ['.trycloudflare.com', '.ngrok-free.app'], + }, plugins: [react(), tsconfigPaths(), VitePWA({ registerType: 'autoUpdate', injectRegister: 'auto', @@ -15,24 +19,94 @@ export default defineConfig({ }, manifest: { + id: '/', name: 'OpenAgri-UserDashboard', short_name: 'OAUserDash', description: 'Dashboard for all OpenAgri services', theme_color: '#558bc9', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: '/', + scope: '/', + screenshots: [ + { + src: 'screenshot-wide.png', + sizes: '1280x720', + type: 'image/png', + form_factor: 'wide', + label: 'OpenAgri UserDashboard', + }, + { + src: 'screenshot-mobile.png', + sizes: '720x1280', + type: 'image/png', + label: 'OpenAgri UserDashboard', + }, + ], }, workbox: { - globPatterns: ['**/*.{js,css,html,svg,png,ico}'], + globPatterns: ['**/*.{js,css,html,svg,png,ico,woff,woff2,ttf,otf}'], + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, cleanupOutdatedCaches: true, clientsClaim: true, // also make sure it's not pre-cached globIgnores: ['**/env-config.js'], + navigateFallbackDenylist: [/^\/examples\//], // NEVER cache the runtime env file runtimeCaching: [ { urlPattern: /\/env-config\.js$/, handler: 'NetworkOnly', // or 'NetworkFirst' with low cache }, + { + urlPattern: /^\/examples\//, + handler: 'NetworkOnly', + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(farmcalendar\/api\/v1\/FarmCalendarActivityTypes\/|pdm\/api\/v1\/(crop|disease|model|threat-model)\/?)/.test(request.url), + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'oa-reference', + expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + (/\/proxy\/farmcalendar\/api\/v1\/(Farm|FarmParcels|FarmAnimals|AgriculturalMachines|Pesticides|Fertilizers)\//.test(request.url) + || /\/me\/?(\?|$)/.test(request.url)), + handler: 'NetworkFirst', + options: { + cacheName: 'oa-user-semi', + networkTimeoutSeconds: 5, + expiration: { maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(farmcalendar\/api\/v1\/(FarmCalendarActivities|Observations|Alerts|CompostOperations|IrrigationOperations)\/|irrigation\/api\/v1\/(dataset|eto\/get-calculations)\/)/.test(request.url), + handler: 'NetworkFirst', + options: { + cacheName: 'oa-user-live', + networkTimeoutSeconds: 3, + expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 }, + }, + }, + { + urlPattern: ({ request }) => + request.method === 'GET' && + /\/proxy\/(weather_data\/api\/data\/(forecast5|flight-forecast5|spray-forecast)\/?|pdm\/api\/v1\/risk-forecast)/.test(request.url), + handler: 'CacheFirst', + options: { + cacheName: 'oa-forecast', + expiration: { maxEntries: 50, maxAgeSeconds: 30 * 60 }, + }, + }, ], }, @@ -43,5 +117,17 @@ export default defineConfig({ suppressWarnings: true, type: 'module', }, - })], + }), + // { + // name: 'inject-env-config', + // transformIndexHtml(html, ctx) { + // if (ctx.bundle) { + // return html.replace( + // '', + // ' \n' + // ) + // } + // }, + // } + ], }) \ No newline at end of file