From 8205f70c2089ef9b374e703df0ce426fb790cc93 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 31 Oct 2023 16:40:13 +0100 Subject: [PATCH] feat(ui-storages): add storages list view and form --- webapp/public/locales/en/main.json | 8 + webapp/public/locales/fr/main.json | 8 + .../explore/Modelization/Areas/AreasTab.tsx | 4 + .../Modelization/Areas/Storages/Fields.tsx | 65 ++++++ .../Modelization/Areas/Storages/Form.tsx | 86 ++++++++ .../Modelization/Areas/Storages/Matrix.tsx | 141 +++++++++++++ .../Modelization/Areas/Storages/index.tsx | 197 ++++++++++++++++++ .../Modelization/Areas/Storages/utils.ts | 110 ++++++++++ .../Modelization/Areas/Thermal/index.tsx | 46 ++-- webapp/src/components/App/index.tsx | 7 + 10 files changed, 645 insertions(+), 27 deletions(-) create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 57c6413c42..831ec4a9fd 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -411,6 +411,14 @@ "study.modelization.hydro.correlation.coefficient": "Coeff. (%)", "study.modelization.hydro.allocation.viewMatrix": "View all allocations", "study.modelization.hydro.allocation.error.field.delete": "Error when deleting the allocation", + "study.modelization.storages": "Storages", + "study.modelization.storages.capacities": "Injection/withdrawal capacities", + "study.modelization.storages.ruleCurves": "Rule Curves", + "study.modelization.storages.inflows": "inflows", + "study.modelization.storages.chargeCapacity": "Charge capacity", + "study.modelization.storages.dischargeCapacity": "Discharge capacity", + "study.modelization.storages.lowerRuleCurve": "Lower rule curve", + "study.modelization.storages.upperRuleCurve": "Upper rule curve", "study.modelization.wind": "Wind", "study.modelization.solar": "Solar", "study.modelization.renewables": "Renewables Clus.", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 887d5691a6..b9b7396a8e 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -412,6 +412,14 @@ "study.modelization.hydro.correlation.coefficient": "Coeff. (%)", "study.modelization.hydro.allocation.viewMatrix": "Voir les allocations", "study.modelization.hydro.allocation.error.field.delete": "Erreur lors de la suppression de l'allocation", + "study.modelization.storages": "Stockages", + "study.modelization.storages.capacities": "Capacités d'injection / soutirage", + "study.modelization.storages.ruleCurves": "Courbe guides", + "study.modelization.storages.inflows": "Apports", + "study.modelization.storages.chargeCapacity": "Capacité de charge", + "study.modelization.storages.dischargeCapacity": "Capacité de décharge", + "study.modelization.storages.lowerRuleCurve": "Courbe guide inférieure", + "study.modelization.storages.upperRuleCurve": "Courbe guide supérieure", "study.modelization.wind": "Éolien", "study.modelization.solar": "Solaire", "study.modelization.renewables": "Clus. Renouvelables", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx index 2fe6ac4ce5..b9ed5d78cb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/AreasTab.tsx @@ -32,6 +32,10 @@ function AreasTab(props: Props) { label: t("study.modelization.thermal"), path: `/studies/${study.id}/explore/modelization/area/${areaId}/thermal`, }, + { + label: t("study.modelization.storages"), + path: `/studies/${study.id}/explore/modelization/area/${areaId}/storages`, + }, { label: t("study.modelization.hydro"), path: `/studies/${study.id}/explore/modelization/area/${areaId}/hydro`, diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx new file mode 100644 index 0000000000..1c21ddee64 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from "react-i18next"; +import NumberFE from "../../../../../../common/fieldEditors/NumberFE"; +import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; +import StringFE from "../../../../../../common/fieldEditors/StringFE"; +import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; +import Fieldset from "../../../../../../common/Fieldset"; +import { useFormContextPlus } from "../../../../../../common/Form"; +import { STORAGE_GROUP_OPTIONS, Storage } from "./utils"; + +function Fields() { + const [t] = useTranslation(); + const { control } = useFormContextPlus(); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( +
+ + + + + + + + +
+ ); +} + +export default Fields; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx new file mode 100644 index 0000000000..d246d1a20c --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx @@ -0,0 +1,86 @@ +import { useCallback } from "react"; +import { Box, Button } from "@mui/material"; +import { useParams, useOutletContext, useNavigate } from "react-router-dom"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { useTranslation } from "react-i18next"; +import { StudyMetadata } from "../../../../../../../common/types"; +import Form from "../../../../../../common/Form"; +import Fields from "./Fields"; +import { SubmitHandlerPlus } from "../../../../../../common/Form/types"; +import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; +import { getCurrentAreaId } from "../../../../../../../redux/selectors"; +import { Storage, getStorage, updateStorage } from "./utils"; +import Matrix from "./Matrix"; +import { nameToId } from "../../../../../../../services/utils"; +import useNavigateOnCondition from "../../../../../../../hooks/useNavigateOnCondition"; + +function StorageForm() { + const { t } = useTranslation(); + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const navigate = useNavigate(); + const areaId = useAppSelector(getCurrentAreaId); + const { storageId = "" } = useParams(); + + useNavigateOnCondition({ + deps: [areaId], + to: `../storages`, + }); + + // prevent re-fetch while useNavigateOnCondition event occurs + const defaultValues = useCallback(() => { + return getStorage(study.id, areaId, storageId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + //////////////////////////////////////////////////////////////// + // Event handlers + //////////////////////////////////////////////////////////////// + + const handleSubmit = ({ dirtyValues }: SubmitHandlerPlus) => { + return updateStorage(study.id, areaId, storageId, dirtyValues); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + +
+ + + + + +
+ ); +} + +export default StorageForm; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx new file mode 100644 index 0000000000..25cb6ae960 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Matrix.tsx @@ -0,0 +1,141 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import * as React from "react"; +import * as R from "ramda"; +import { styled } from "@mui/material"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import { MatrixStats, StudyMetadata } from "../../../../../../../common/types"; +import MatrixInput from "../../../../../../common/MatrixInput"; +import { Storage } from "./utils"; +import SplitLayoutView from "../../../../../../common/SplitLayoutView"; + +export const StyledTab = styled(Tabs)({ + width: "98%", + borderBottom: 1, + borderColor: "divider", +}); + +interface Props { + study: StudyMetadata; + areaId: StudyMetadata["id"]; + storageId: Storage["id"]; +} + +function Matrix({ study, areaId, storageId }: Props) { + const [t] = useTranslation(); + const [value, setValue] = React.useState(0); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + setValue(v)}> + + + + + + {R.cond([ + [ + () => value === 0, + () => ( + + } + right={ + + } + sx={{ + mt: 1, + ".SplitLayoutView__Left": { + width: "50%", + }, + ".SplitLayoutView__Right": { + height: 1, + width: "50%", + }, + }} + /> + ), + ], + [ + () => value === 1, + () => ( + + } + right={ + + } + sx={{ + mt: 1, + ".SplitLayoutView__Left": { + width: "50%", + }, + ".SplitLayoutView__Right": { + height: 1, + width: "50%", + }, + }} + /> + ), + ], + [ + R.T, + () => ( + + ), + ], + ])()} + + + ); +} + +export default Matrix; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx new file mode 100644 index 0000000000..670fecad5b --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx @@ -0,0 +1,197 @@ +/* eslint-disable camelcase */ +import { useMemo } from "react"; +import { MRT_ColumnDef } from "material-react-table"; +import { Box, Chip } from "@mui/material"; +import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; +import { StudyMetadata } from "../../../../../../../common/types"; +import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; +import { getCurrentAreaId } from "../../../../../../../redux/selectors"; +import usePromise from "../../../../../../../hooks/usePromise"; +import GroupedDataTable from "../../../../../../common/GroupedDataTable"; +import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; +import { + Storage, + StorageGroup, + getStorages, + deleteStorages, + STORAGE_GROUP_OPTIONS, + createStorage, +} from "./utils"; +import SimpleContent from "../../../../../../common/page/SimpleContent"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; + +function Storages() { + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const navigate = useNavigate(); + const location = useLocation(); + const currentAreaId = useAppSelector(getCurrentAreaId); + const groups = Object.values(StorageGroup); + + const storages = usePromise( + () => getStorages(study.id, currentAreaId), + [study.id, currentAreaId], + ); + + const { totalWithdrawalNominalCapacity, totalInjectionNominalCapacity } = + useMemo(() => { + if (!storages.data) { + return { + totalWithdrawalNominalCapacity: 0, + totalInjectionNominalCapacity: 0, + }; + } + + return storages.data.reduce( + (acc, { withdrawalNominalCapacity, injectionNominalCapacity }) => { + acc.totalWithdrawalNominalCapacity += withdrawalNominalCapacity; + acc.totalInjectionNominalCapacity += injectionNominalCapacity; + return acc; + }, + { + totalWithdrawalNominalCapacity: 0, + totalInjectionNominalCapacity: 0, + }, + ); + }, [storages]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: "Name", + size: 100, + Cell: ({ renderedCellValue, row }) => { + const storageId = row.original.id; + return ( + navigate(`${location.pathname}/${storageId}`)} + > + {renderedCellValue} + + ); + }, + }, + { + accessorKey: "group", + header: "Group", + size: 50, + filterVariant: "select", + filterSelectOptions: STORAGE_GROUP_OPTIONS, + muiTableHeadCellProps: { + align: "left", + }, + muiTableBodyCellProps: { + align: "left", + }, + Footer: () => ( + Total: + ), + }, + { + accessorKey: "injectionNominalCapacity", + header: "Nominal capacity", + size: 50, + AggregatedCell: ({ cell }) => ( + + {cell.getValue()} + + ), + Footer: () => ( + {totalInjectionNominalCapacity} + ), + }, + { + accessorKey: "withdrawalNominalCapacity", + header: "Withdrawal capacity", + size: 50, + aggregationFn: "sum", + AggregatedCell: ({ cell }) => ( + + {cell.getValue()} + + ), + Footer: () => ( + {totalWithdrawalNominalCapacity} + ), + }, + { + accessorKey: "reservoirCapacity", + header: "Reservoir capacity", + size: 50, + Cell: ({ cell }) => `${cell.getValue() * 100} MWh`, + }, + { + accessorKey: "efficiency", + header: "Efficiency", + size: 50, + Cell: ({ cell }) => `${cell.getValue() * 100} %`, + }, + { + accessorKey: "initialLevel", + header: "Initial Level", + size: 50, + }, + { + accessorKey: "initialLevelOptim", + header: "Initial Level Optim", + size: 50, + filterVariant: "checkbox", + Cell: ({ cell }) => ( + () ? "Yes" : "No"} + color={cell.getValue() ? "success" : "error"} + size="small" + /> + ), + }, + ], + [ + location.pathname, + navigate, + totalInjectionNominalCapacity, + totalWithdrawalNominalCapacity, + ], + ); + + //////////////////////////////////////////////////////////////// + // Event handlers + //////////////////////////////////////////////////////////////// + + const handleCreateRow = ({ id, ...storage }: Storage) => { + return createStorage(study.id, currentAreaId, storage); + }; + + const handleDeleteSelection = (ids: string[]) => { + return deleteStorages(study.id, currentAreaId, ids); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + } + ifResolved={(data) => ( + + )} + ifRejected={(error) => } + /> + ); +} + +export default Storages; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts new file mode 100644 index 0000000000..350b30a93d --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts @@ -0,0 +1,110 @@ +import { StudyMetadata, Area } from "../../../../../../../common/types"; +import client from "../../../../../../../services/api/client"; + +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// + +export enum StorageGroup { + PspOpen = "PSP_open", + PspClosed = "PSP_closed", + Pondage = "Pondage", + Battery = "Battery", + Other1 = "Other1", + Other2 = "Other2", + Other3 = "Other3", + Other4 = "Other4", + Other5 = "Other5", +} + +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +export interface Storage { + id: string; + name: string; + group: StorageGroup; + injectionNominalCapacity: number; + withdrawalNominalCapacity: number; + reservoirCapacity: number; + efficiency: number; + initialLevel: number; + initialLevelOptim: boolean; +} + +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// + +export const STORAGE_GROUP_OPTIONS = Object.values(StorageGroup); + +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + +const getStoragesUrl = ( + studyId: StudyMetadata["id"], + areaId: Area["name"], +): string => `/v1/studies/${studyId}/areas/${areaId}/storages`; + +const getStorageUrl = ( + studyId: StudyMetadata["id"], + areaId: Area["name"], + storageId: Storage["id"], +): string => `${getStoragesUrl(studyId, areaId)}/${storageId}`; + +async function makeRequest( + method: "get" | "post" | "patch" | "delete", + url: string, + data?: Partial | { data: Array }, +): Promise { + const res = await client[method](url, data); + return res.data; +} + +export async function getStorages( + studyId: StudyMetadata["id"], + areaId: Area["name"], +): Promise { + return makeRequest("get", getStoragesUrl(studyId, areaId)); +} + +export async function getStorage( + studyId: StudyMetadata["id"], + areaId: Area["name"], + storageId: Storage["id"], +): Promise { + return makeRequest("get", getStorageUrl(studyId, areaId, storageId)); +} + +export async function updateStorage( + studyId: StudyMetadata["id"], + areaId: Area["name"], + storageId: Storage["id"], + data: Partial, +): Promise { + return makeRequest( + "patch", + getStorageUrl(studyId, areaId, storageId), + data, + ); +} + +export async function createStorage( + studyId: StudyMetadata["id"], + areaId: Area["name"], + data: Partial, +): Promise { + return makeRequest("post", getStoragesUrl(studyId, areaId), data); +} + +export function deleteStorages( + studyId: StudyMetadata["id"], + areaId: Area["name"], + storageIds: Array, +): Promise { + return makeRequest("delete", getStoragesUrl(studyId, areaId), { + data: storageIds, + }); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx index c483d92ffc..a89c1955dd 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx @@ -20,6 +20,7 @@ import GroupedDataTable from "../../../../../../common/GroupedDataTable"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; import SimpleContent from "../../../../../../common/page/SimpleContent"; import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; function Thermal() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -29,13 +30,7 @@ function Thermal() { const currentAreaId = useAppSelector(getCurrentAreaId); const groups = Object.values(ThermalClusterGroup); - const { - data: clusters, - isLoading, - isRejected, - isResolved, - error, - } = usePromiseWithSnackbarError( + const clusters = usePromiseWithSnackbarError( () => getThermalClusters(study.id, currentAreaId), { errorMessage: t("studies.error.retrieveData"), @@ -51,7 +46,7 @@ function Thermal() { */ const clustersWithCapacity = useMemo( () => - clusters?.map((cluster) => { + clusters?.data?.map((cluster) => { const { unitCount, nominalCapacity, enabled } = cluster; const installedCapacity = unitCount * nominalCapacity; const enabledCapacity = enabled ? installedCapacity : 0; @@ -229,25 +224,22 @@ function Thermal() { // JSX //////////////////////////////////////////////////////////////// - if (isLoading) { - return ; - } - - if (isRejected) { - return ; - } - - if (isResolved) { - return ( - - ); - } + return ( + } + ifResolved={() => ( + + )} + ifRejected={(error) => } + /> + ); } export default Thermal; diff --git a/webapp/src/components/App/index.tsx b/webapp/src/components/App/index.tsx index 1fe57e4003..ee0add0d07 100644 --- a/webapp/src/components/App/index.tsx +++ b/webapp/src/components/App/index.tsx @@ -53,6 +53,8 @@ import Districts from "./Singlestudy/explore/Modelization/Map/MapConfig/District import InflowStructure from "./Singlestudy/explore/Modelization/Areas/Hydro/InflowStructure"; import Allocation from "./Singlestudy/explore/Modelization/Areas/Hydro/Allocation"; import Correlation from "./Singlestudy/explore/Modelization/Areas/Hydro/Correlation"; +import Storages from "./Singlestudy/explore/Modelization/Areas/Storages"; +import StorageForm from "./Singlestudy/explore/Modelization/Areas/Storages/Form"; import ThermalForm from "./Singlestudy/explore/Modelization/Areas/Thermal/Form"; function App() { @@ -86,6 +88,11 @@ function App() { path="thermal/:clusterId" element={} /> + } /> + } + /> }