diff --git a/src/client/app/components/TooltipHelpComponent.tsx b/src/client/app/components/TooltipHelpComponent.tsx index f48de775ea..2265abde09 100644 --- a/src/client/app/components/TooltipHelpComponent.tsx +++ b/src/client/app/components/TooltipHelpComponent.tsx @@ -29,6 +29,7 @@ export default function TooltipHelpComponent(props: TooltipHelpProps) { const helpUrl = useAppSelector(selectHelpUrl); const helpLinks: Record> = { + 'help.admin.baselinecreate': { link: `${helpUrl}/adminBaselineCreating/` }, 'help.admin.conversioncreate': { link: `${helpUrl}/adminConversionCreating/` }, 'help.admin.conversionedit': { link: `${helpUrl}/adminConversionEditing/` }, 'help.admin.conversionview': { link: `${helpUrl}/adminConversionViewing/` }, diff --git a/src/client/app/components/baseline/BaselineViewComponent.tsx b/src/client/app/components/baseline/BaselineViewComponent.tsx new file mode 100644 index 0000000000..963b1576e1 --- /dev/null +++ b/src/client/app/components/baseline/BaselineViewComponent.tsx @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button } from 'reactstrap'; +import { Baseline } from '../../types/redux/baselines'; +import '../../styles/card-page.css'; +import { useTranslate } from '../../redux/componentHooks'; +import EditBaselineModalComponent from './EditBaselineModalComponent'; + + + +interface BaselineViewComponentProps { + baseline: Baseline; +} + +/** + * Defines the baseline info card + * @param props defined above + * @returns Baseline element + */ +export default function BaselineViewComponent(props: BaselineViewComponentProps) { + const translate = useTranslate(); + // Don't check if admin since only an admin is allow to route to this page. + + // Edit Modal Show + const [showEditModal, setShowEditModal] = useState(false); + + const handleShow = () => { + setShowEditModal(true); + }; + + const handleClose = () => { + setShowEditModal(false); + }; + + // // Create header from sourceId, destinationId identifiers + // const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + + // unitDataById[props.conversion.destinationId]?.identifier); + + + return ( +
+ {/*
+ {conversionIdentifier} +
*/} +
+ {translate(`TrueFalseType.${props.baseline.isActive.toString()}`)} +
+
+ {/* Only show first 30 characters so card does not get too big. Should limit to one line */} + {props.baseline.note?.slice(0, 29)} +
+
+ + {/* Creates a child ConversionModalEditComponent */} + +
+
+ ); +} \ No newline at end of file diff --git a/src/client/app/components/baseline/CreateBaselineModalComponent.tsx b/src/client/app/components/baseline/CreateBaselineModalComponent.tsx new file mode 100644 index 0000000000..36a0bf889d --- /dev/null +++ b/src/client/app/components/baseline/CreateBaselineModalComponent.tsx @@ -0,0 +1,266 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button, Col, Container, FormGroup, Input, Label, Modal, ModalBody, ModalHeader, Row } from 'reactstrap'; +import { baselineApi } from '../../redux/api/baselineApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectDefaultCreateBaselineValues } from '../../redux/selectors/adminSelectors'; +import '../../styles/modal.css'; +import { TrueFalseType } from '../../types/items'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +// import { isAction } from '@reduxjs/toolkit'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; + +/** + * Defines the create baseline modal form + * @returns Baseline create element + */ +export default function CreateBaselineModalComponent({ currentMeterId }: { currentMeterId: number }) { + // /* Hooks & Selectors */ + const translate = useTranslate(); + const [addBaselineMutation] = baselineApi.useAddBaselineMutation(); + + + + const defaultValues = useAppSelector(state => selectDefaultCreateBaselineValues(state, currentMeterId)); + // /* End Hooks & Selectors */ + + /* Utility Functions */ + // Utility to get the initial pattern state + const getInitialPatternState = () => ({ + Baseline: { + meterId: currentMeterId, + isActive: defaultValues.isActive, + note: defaultValues.note + }, + initialSegment: { + baselineValue: defaultValues.baselineValue, + startTime: defaultValues.startTime, + endTime: defaultValues.endTime, + segmentNote: defaultValues.segmentNote + } + }); + + // States + const [showModal, setShowModal] = useState(false); + const [showWarningModal, setShowWarningModal] = useState(false); + const [warningMessage, setWarningMessage] = useState(''); + const [patternState, setPatternState] = useState(getInitialPatternState()); + + // Reset the state to default values + const resetState = () => { + setPatternState(getInitialPatternState()); + }; + + const submitBaseline = () => { + // Close modal first to avoid repeat clicks + setShowModal(false); + addBaselineMutation({ + meterId: patternState.Baseline.meterId, + isActive: patternState.Baseline.isActive, + note: patternState.Baseline.note, + baselineValue: patternState.initialSegment.baselineValue, + // calcStart: null, + // calcEnd: null, + segmentNote: patternState.initialSegment.segmentNote + }).unwrap() + .then(() => { + showSuccessNotification(translate('baseline.create.success')); + }) + .catch(error => { + showErrorNotification(translate('baseline.create.failure') + error); + }); + resetState(); + }; + /* End Utility Functions */ + + // /* Handlers */ + const handleInitialSegmentNoteChange = (e: React.ChangeEvent) => { + setPatternState(prev => ({ + ...prev, + initialSegment: { + ...prev.initialSegment, + segmentNote: e.target.value + } + })); + }; + + const handleStringChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setPatternState(prev => ({ + ...prev, + Baseline: { + ...prev.Baseline, + [name]: value + } + })); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newValue = Number(value); + setPatternState(prev => ({ + ...prev, + initialSegment: { + ...prev.initialSegment, + [name]: newValue + } + })); + }; + + const handleBooleanChange = (e: React.ChangeEvent) => { + setPatternState({ ...patternState, [e.target.name]: JSON.parse(e.target.value) }); + }; + + const handleClose = () => { + setShowModal(false); + // resetState(); + }; + + const handleShow = () => setShowModal(true); + + const handleWarningConfirm = () => { + //Close the warning modal + setShowWarningModal(false); + // submitDay(); + }; + + const handleWarningCancel = () => { + //Close the warning modal + setShowWarningModal(false); + }; + + // Submit + const handleSubmit = () => { + // Show warning modal if baseline value is 0 + if (patternState.initialSegment.baselineValue === 0) { + setWarningMessage(translate('baseline.value.zero')); + setShowWarningModal(true); + } else { + submitBaseline(); + } + }; + /* End Handlers */ + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipCreateBaselineView: 'help.admin.baselinecreate' + }; + + return ( + <> + + + + + + +
+ +
+
+ + + {/* Active status input for overall baseline*/} + + + handleBooleanChange(e)}> + {Object.keys(TrueFalseType).map(key => { + return (); + })} + + + {/* Note input for overall baseline*/} + + + handleStringChange(e)} + value={patternState.Baseline.note} /> + + {/*Initial baseline segment*/} +
+ +
+ + + {/* Baseline value input*/} + + + handleNumberChange(e)} /> + + + + + + {/* Start time input*/} + + + + + + + {/* End time input*/} + + + + + + + {/* Note input for initial pattern*/} + + + handleInitialSegmentNoteChange(e)} + value={patternState.initialSegment.segmentNote} /> + +
+
+
+ + + ); +} \ No newline at end of file diff --git a/src/client/app/components/baseline/DeleteBaselineSegmentModalComponent.tsx b/src/client/app/components/baseline/DeleteBaselineSegmentModalComponent.tsx new file mode 100644 index 0000000000..d1a12c2a6f --- /dev/null +++ b/src/client/app/components/baseline/DeleteBaselineSegmentModalComponent.tsx @@ -0,0 +1,85 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { Button } from 'reactstrap'; +import { baselineSegmentsApi } from '../../redux/api/baselineApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { BaselineSegment } from '../../types/redux/baselines'; +import { showErrorNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface DeleteBaselineSegmentComponentProps { + /** + * The direction to delete the segment + */ + direction: 'earlier' | 'later'; + + /** + * The baseline segment to delete + */ + baselineSegment: BaselineSegment; +} +/** + * Defines a button that opens a modal to delete a baseline segment. + * The deletion can be done earlier or later based on the direction prop. + * @param props The properties for the component + * @returns A button element + */ +export default function DeleteBaselineSegmentComponent(props: DeleteBaselineSegmentComponentProps): React.ReactElement { + const translate = useTranslate(); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + + const handleShowDeleteModal = () => setShowDeleteModal(true); + const handleHideDeleteModal = () => setShowDeleteModal(false); + + const [deleteBaselineSegmentEarlierMutation] = baselineSegmentsApi.useDeleteBaselineSegmentEarlierMutation(); + const [deleteBaselineSegmentLaterMutation] = baselineSegmentsApi.useDeleteBaselineSegmentLaterMutation(); + + const handleDeleteBaselineSegment = async () => { + try { + if (props.direction === 'earlier') { + await deleteBaselineSegmentEarlierMutation(props.baselineSegment).unwrap(); + } else if (props.direction === 'later') { + await deleteBaselineSegmentLaterMutation(props.baselineSegment).unwrap(); + } else { + throw new Error( + translate('baseline.segments.delete.invalid.direction').replace('{direction}', props.direction) + ); + } + handleHideDeleteModal(); + } catch (error) { + showErrorNotification(error); + } + }; + + const deleteButtonText = props.direction === 'earlier' + ? translate('delete.earlier') : translate('delete.later'); + + const deleteConfirmationMessage = props.direction === 'earlier' + ? translate('baseline.segments.delete.confirm.earlier') + : translate('baseline.segments.delete.confirm.later'); + + return ( + <> + + + {/* Delete baseline segment confirmation modal */} + + + ); +} \ No newline at end of file diff --git a/src/client/app/components/baseline/EditBaselineModalComponent.tsx b/src/client/app/components/baseline/EditBaselineModalComponent.tsx new file mode 100644 index 0000000000..1a4b7522b9 --- /dev/null +++ b/src/client/app/components/baseline/EditBaselineModalComponent.tsx @@ -0,0 +1,298 @@ +import * as React from 'react'; +// Realize that * is already imported from react +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + Button, + Container, + FormGroup, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Table, + Pagination, + PaginationItem, + PaginationLink +} +from 'reactstrap'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import { baselineApi, baselineSegmentsApi } from '../../redux/api/baselineApi'; +// import { selectMeterDataById } from '../../redux/api/metersApi'; +// import { useAppSelector } from '../../redux/reduxHooks'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import { Baseline, BaselineSegment } from '../../types/redux/baselines'; +import { useTranslate } from '../../redux/componentHooks'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +// import MetersDetailComponent from 'components/meters/MetersDetailComponent'; +import SplitBaselineSegmentComponent from './SplitBaselineSegmentComponent'; +import DeleteBaselineSegmentComponent from './DeleteBaselineSegmentModalComponent'; +import EditBaselineSegmentModalComponent from './EditBaselineSegmentModalComponent'; +import '../../styles/modal.css'; + +interface EditBaselineModalComponentProps { + show: boolean; + baseline: Baseline; + baselineIdentifier: string; // think about whether to get rid of this + // passed in to handle opening the modalq2 + handleShow: () => void; + // passed in to handle closing the modal + handleClose: () => void; +} + +/** + * Defines the edit baselin modal form + * @param props Props for the component + * @returns Baseline edit element + */ +export default function EditBaselineModalComponent(props: EditBaselineModalComponentProps) { + const PER_PAGE = 10; + const translate = useTranslate(); + // const meterDataById = useAppSelector(selectMeterDataById); + + // Set existing baseline values + const values = { ...props.baseline }; + + /* State */ + // Handlers for each type of input change + const [state, setState] = useState(values); + + const handleStringChange = (e: React.ChangeEvent) => { + setState({ ...state, [e.target.name]: e.target.value }); + }; + + const handleBooleanChange = (e: React.ChangeEvent) => { + setState({ ...state, [e.target.name]: JSON.parse(e.target.value) }); + }; + + // const handleNumberChange = (e: React.ChangeEvent) => { + // setState({ ...state, [e.target.name]: Number(e.target.value) }); + // }; + /* End State */ + + // Performs checks to warn the admin if the baseline is inactive + // TODO: Call in submit + const checkState = () => { + if (!state.isActive) { + alert(translate('baseline.inactiveWarning')); // make a notification + } + }; + + const { data: baselineSegments = [] } = baselineSegmentsApi.useGetBaselineSegmentsByMeterIdQuery(props.baseline.meterId); + const [ editBaselineMutation, { isLoading: isSaving }] = baselineApi.useEditBaselineMutation(); + const handleSubmit = () => { + props.handleClose(); + editBaselineMutation(state).unwrap() + .then(() => { + showSuccessNotification(translate('baseline.edit.success')); + }).catch(error => { + showErrorNotification(`${translate('baseline.edit.error')} ${error}`); + }); + }; + + // Pagination + const [page, setPage] = React.useState(1); + const totalPages = Math.ceil(baselineSegments.length / PER_PAGE); + const paged = baselineSegments.slice((page - 1) * PER_PAGE, page * PER_PAGE); + + const isUnchanged = React.useMemo(() => { + return props.baseline.isActive === state.isActive && props.baseline.note === state.note; + }, [props.baseline, state]); + + // State to hold the modal for editing a baseline segment + const [showEditSegmentModal, setShowEditSegmentModal] = React.useState(false); + const [editSegment, setEditSegment] = React.useState(null); + + // Function to open the edit segment modal + const handleShowEditSegmentModal = (segment: BaselineSegment) => { + setEditSegment(segment); + setShowEditSegmentModal(true); + }; + // Function to close the edit segment modal + const handleCloseEditSegmentModal = () => { + setShowEditSegmentModal(false); + setEditSegment(null); + }; + + // State to hold the segment for showing segment notes in a modal + const [noteSegment, setNoteSegment] = React.useState(null); + // The note modal is shown when a segment note is clicked + const showNoteModal = Boolean(noteSegment); + const handleShowNoteModal = (segment: BaselineSegment) => { + setNoteSegment(segment); + }; + const handleCloseNoteModal = () => { + setNoteSegment(null); + }; + + // Delete baseline confirmation modal + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [deleteBaselineMutation, { isLoading: isDeleting }] = baselineApi.useDeleteBaselineMutation(); + + // Function to handle deleting the baseline + const handleDeleteBaseline = () => { + setShowDeleteModal(false); + deleteBaselineMutation(state.meterId).unwrap() + .then(() => { + showSuccessNotification(translate('baseline.delete.success')); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(`${translate('baseline.delete.error')} ${error}`); + }); + }; + + return ( + <> +

Edit baseline

+ + + + +
+ +
+
+ + + + {/* Name */} + + + + + + + + {/* Note */} + + + + + +
+ {/* Table */} +
+ + + + + + + + + + + + + + + {paged?.map(seg => ( + + + + + + // This needs to have the same logic as conversions + + + + + + ))} + +
{seg.startTime} - {seg.endTime}{seg.baselineValue} handleShowNoteModal(seg)} + aria-label={seg.note} + > + {(seg.note ?? '').length > 30 ? `${seg.note?.slice(0, 30)} ...` : seg.note || ''} + + + + {/* only show split buttons if segment is longer than 1 hour */} + {seg.endTime - seg.startTime > 1 && } + + {seg.endTime - seg.startTime > 1 && } + + {/* first segment cannot delete earlier */} + {seg.startTime > 0 && + + } + + {/* last segment cannot delete later */} + {seg.endTime < 24 && + + } +
+ + {/* Pagination */} + {baselineSegments && baselineSegments.length > PER_PAGE && ( + + + setPage(1)} /> + + + setPage(p => p - 1)} /> + + {Array.from({ length: totalPages }, (_, i) => ( + + setPage(i + 1)}>{i + 1} + + ))} + + setPage(p => p + 1)} /> + + + setPage(totalPages)} /> + + + )} +
+
+ + + {/* Delete baseline */} + + + + + + {/* Delete confirmation modal */} + setShowDeleteModal(false)} + actionConfirmText={translate('baseline.delete.button')} + actionRejectText={translate('cancel')} + /> + + {/* Segment note modal */} + + + {noteSegment?.startTime} - {noteSegment?.endTime} + + + {noteSegment?.note} + + +
+ + {/* Edit Baseline Segment modal */} + {editSegment && } + + ); +} \ No newline at end of file diff --git a/src/client/app/components/baseline/EditBaselineSegmentModalComponent.tsx b/src/client/app/components/baseline/EditBaselineSegmentModalComponent.tsx new file mode 100644 index 0000000000..b027279ba0 --- /dev/null +++ b/src/client/app/components/baseline/EditBaselineSegmentModalComponent.tsx @@ -0,0 +1,201 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button, Col, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; +import { baselineSegmentsApi } from '../../redux/api/baselineApi'; +import { BaselineSegment, UpdateBaselineSegmentPayload } from '../../types/redux/baselines'; +import { showErrorNotification } from '../../utils/notifications'; + +interface EditBaselineSegmentModalComponentProps { + show: boolean; + baselineSegment: BaselineSegment; + /** + * Function to run when edit modal closes + */ + handleClose: () => void; +} +/** + * /** + * Defines a modal that allows editing of an existing Baseline Segment. + * @param props - The properties for the component + * @returns Baseline segment edit element + */ +export default function EditBaselineSegmentModalComponent(props: EditBaselineSegmentModalComponentProps): React.ReactElement { + + const [baselineSegment, setBaselineSegment] = React.useState( + { ...props.baselineSegment, originalStartHour: props.baselineSegment.startTime, originalEndHour: props.baselineSegment.endTime } + ); + + // Fetch baseline segments by meter ID to validate start and end hours + const { data: baselineSegments = [] } = baselineSegmentsApi.useGetBaselineSegmentsByMeterIdQuery(props.baselineSegment.meterId); + const [editBaselineSegmentMutation, { isLoading: isSaving }] = baselineSegmentsApi.useEditBaselineSegmentMutation(); + const handleStringChange = (e: React.ChangeEvent) => { + setBaselineSegment({ ...baselineSegment, [e.target.name]: e.target.value }); + }; + const handleNumberChange = (e: React.ChangeEvent) => { + setBaselineSegment({ ...baselineSegment, [e.target.name]: Number(e.target.value) }); + }; + const handleSubmit = () => { + props.handleClose(); + editBaselineSegmentMutation(baselineSegment).unwrap() + .then(() => { + }) + .catch(error => { + showErrorNotification(error); + }); + }; + // The segment immediately before the current segment, if it exists + const earlierSegment = React.useMemo(() => { + const segmentIndex = baselineSegments.findIndex(s => s.meterId === props.baselineSegment.meterId); + if (segmentIndex > 0) { + return baselineSegments[segmentIndex - 1]; + } + return null; + }, [baselineSegments, props.baselineSegment.meterId]); + // The segment immediately after the current segment, if it exists + const laterSegment = React.useMemo(() => { + const segmentIndex = baselineSegments.findIndex(s => s.meterId === props.baselineSegment.meterId); + if (segmentIndex < baselineSegments.length - 1) { + return baselineSegments[segmentIndex + 1]; + } + return null; + }, [baselineSegments, props.baselineSegment.meterId]); + // Validate start hour + const isStartHourValid = React.useMemo(() => { + if (!Number.isInteger(baselineSegment.startTime) || baselineSegment.startTime < 0 || baselineSegment.startTime >= baselineSegment.endTime) { + return false; + } + // Check if the start hour does not conflict with existing segments + if (earlierSegment && baselineSegment.startTime <= earlierSegment.startTime) { + return false; + } + + return true; + }, [baselineSegment.startTime, baselineSegment.endTime, earlierSegment]); + // Validate end hour + const isEndHourValid = React.useMemo(() => { + if (!Number.isInteger(baselineSegment.endTime) || baselineSegment.endTime <= baselineSegment.startTime || baselineSegment.endTime > 24) { + return false; + } + // Check if the end hour does not conflict with existing segments + if (laterSegment && baselineSegment.endTime >= laterSegment.endTime) { + return false; + } + + return true; + }, [baselineSegment.startTime, baselineSegment.endTime, laterSegment]); + + // Validate the segment as a whole + // It should have valid start and end hours + const isSegmentValid = React.useMemo(() => { + return isStartHourValid && isEndHourValid; + }, [baselineSegment.startTime, baselineSegment.endTime]); + + const isSegmentUnchanged = React.useMemo(() => { + return props.baselineSegment.baselineValue === baselineSegment.baselineValue && + props.baselineSegment.startTime === baselineSegment.startTime && + props.baselineSegment.endTime === baselineSegment.endTime && + props.baselineSegment.note === baselineSegment.note; + }, [props.baselineSegment, baselineSegment]); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Discard changes */} + + {/* Save changes */} + + + + ) +}; \ No newline at end of file diff --git a/src/client/app/components/baseline/SplitBaselineSegmentComponent.tsx b/src/client/app/components/baseline/SplitBaselineSegmentComponent.tsx new file mode 100644 index 0000000000..05e9900a82 --- /dev/null +++ b/src/client/app/components/baseline/SplitBaselineSegmentComponent.tsx @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { baselineSegmentsApi } from '../../redux/api/baselineApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { selectDefaultSplitBaselineSegmentValues } from '../../redux/selectors/adminSelectors'; +import { BaselineSegment, SplitBaselineSegmentPayload } from '../../types/redux/baselines'; +import { showErrorNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface SplitBaselineSegmentComponentProps { + /** + * The direction to split the segment + */ + direction: 'earlier' | 'later'; + + /** + * The baseline segment to split + */ + baselineSegment: BaselineSegment; +} + +/** + * Defines a button that opens a modal to split a baseline segment into two segments. + * The split can be done earlier or later based on the direction prop. + * @param props The properties for the component + * @returns A button element + */ +export default function SplitBaselineSegmentComponent(props: SplitBaselineSegmentComponentProps): React.ReactElement { + const translate = useTranslate(); + + const [splitHour, setSplitHour] = React.useState(-999); + + const defaultSegmentValues = selectDefaultSplitBaselineSegmentValues(props.baselineSegment); + + // New segment to be created after the split + const [newSegment, setNewSegment] = React.useState(defaultSegmentValues); + + const [showSplitModal, setShowSplitModal] = React.useState(false); + + // State for the warning modal + const [showWarningModal, setShowWarningModal] = React.useState(false); + const [warningMessage, setWarningMessage] = React.useState(''); + + const [splitEarlierMutation] = baselineSegmentsApi.useSplitEarlierMutation(); + const [splitLaterMutation] = baselineSegmentsApi.useSplitLaterMutation(); + + const handleShowSplitModal = () => setShowSplitModal(true); + const handleHideSplitModal = () => setShowSplitModal(false); + + const handleSplitInputChange = (e: React.ChangeEvent) => { + setSplitHour(Number(e.target.value)); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + setNewSegment(prev => ({ + ...prev, + [e.target.name]: Number(e.target.value) + })); + }; + const handleStringChange = (e: React.ChangeEvent) => { + setNewSegment(prev => ({ + ...prev, + [e.target.name]: e.target.value + })); + }; + + // Validate the split hour + // It should be greater than the start hour and less than the end hour + const isSplitValid = React.useMemo(() => { + if (!Number.isInteger(splitHour)) { + return false; + } + return splitHour > props.baselineSegment.startTime && splitHour < props.baselineSegment.endTime; + + }, [splitHour, props.baselineSegment, props.direction]); + + const doMutation = () => { + const mutation = props.direction === 'earlier' + ? splitEarlierMutation + : splitLaterMutation; + + handleHideSplitModal(); + mutation({ ...newSegment, meterId: props.baselineSegment.meterId, splitTime: splitHour }).unwrap() + .then(() => { + }) + .catch(error => { + showErrorNotification(error); + }); + }; + + // Handle the split operation + const handleSubmit = () => { + // Show warning modal if baseline value is 0 + if (newSegment.newBaselineValue === 0) { + setWarningMessage(translate('baseline.baseline.zero')); + setShowWarningModal(true); + return; + } + doMutation(); + }; + + const handleWarningCancel = () => { + setShowWarningModal(false); + }; + + const handleWarningConfirm = () => { + setShowWarningModal(false); + doMutation(); + }; + + return ( + <> + + + + + + + + {/* split hour */} + + + + + {!isSplitValid && translate('split.hour.invalid') + .replace('{start}', String(props.baselineSegment.startTime + 1)) + .replace('{end}', String(props.baselineSegment.endTime - 1))} + + + + + + + + + + + + + + + + + {/* Warning modal if baseline value is zero */} + + + + ); +} diff --git a/src/client/app/components/meters/EditMeterModalComponent.tsx b/src/client/app/components/meters/EditMeterModalComponent.tsx index 01a23d61cd..cf6649874c 100644 --- a/src/client/app/components/meters/EditMeterModalComponent.tsx +++ b/src/client/app/components/meters/EditMeterModalComponent.tsx @@ -13,7 +13,7 @@ import { metersApi, selectMeterById, selectMeterDataById } from '../../redux/api import { selectUnitDataById } from '../../redux/api/unitsApi'; import { useAppSelector } from '../../redux/reduxHooks'; import { - MAX_DATE, MAX_DATE_MOMENT, MAX_ERRORS, MIN_DATE, MIN_DATE_MOMENT, selectGraphicUnitCompatibility + MAX_DATE, MAX_DATE_MOMENT, MAX_ERRORS, MIN_DATE, MIN_DATE_MOMENT, selectBaselineExists, selectGraphicUnitCompatibility } from '../../redux/selectors/adminSelectors'; import '../../styles/modal.css'; import { tooltipBaseStyle } from '../../styles/modalStyle'; @@ -28,6 +28,8 @@ import { useTranslate } from '../../redux/componentHooks'; import TimeZoneSelect from '../TimeZoneSelect'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import CreateBaselineModalComponent from '../baseline/CreateBaselineModalComponent'; +import EditBaselineModalComponent from '../baseline/EditBaselineModalComponent'; interface EditMeterModalComponentProps { show: boolean; @@ -57,6 +59,7 @@ export default function EditMeterModalComponent(props: EditMeterModalComponentPr const groupDataByID = useAppSelector(selectGroupDataById); // TODO should this state be used for the meterState above or would that cause issues? const meterDataByID = useAppSelector(selectMeterDataById); + const [ baselineExists, description, currentBaseline ] = useAppSelector(state => selectBaselineExists(state, props.meter.id)); useEffect(() => { setLocalMeterEdits(cloneDeep(meterState)); }, [meterState]); /* State */ @@ -234,6 +237,31 @@ export default function EditMeterModalComponent(props: EditMeterModalComponentPr } }; + const [showModal, setShowModal] = useState(false); + const handleShowEditBaselineModal = () => setShowModal(true); + const handleCloseEditBaselineModal = () => setShowModal(false); + + const handleCreateEditBaseline = () => { + // Show create modal if baseline does not exist for this meter + if (!baselineExists) { + console.log(description); + console.log("Creating baseline..."); + return ; + } else { + // Retrieve current baseline data using meter ID + // const { data: currentBaseline } = useGetBaselineByMeterIdQuery(props.meter.id); + if (currentBaseline) { + return ; + } + } + }; + const handleNumberChange = (e: React.ChangeEvent) => { setLocalMeterEdits({ ...localMeterEdits, [e.target.name]: Number(e.target.value) }); }; @@ -473,6 +501,13 @@ export default function EditMeterModalComponent(props: EditMeterModalComponentPr value={nullToEmptyString(localMeterEdits.note)} placeholder='Note' /> + + +
+ {handleCreateEditBaseline()} +
+ + {/* cumulative input */} diff --git a/src/client/app/index.tsx b/src/client/app/index.tsx index 83dfee7ad8..244fc98326 100644 --- a/src/client/app/index.tsx +++ b/src/client/app/index.tsx @@ -23,3 +23,5 @@ root.render( < RouteComponent /> ); + +console.log("This works"); diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 58d58b5e14..6b57a8a0cb 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -35,7 +35,9 @@ export const baseApi = createApi({ 'ConversionDetails', 'Units', 'Cik', - 'Readings' + 'Readings', + 'Baselines', + 'BaselineSegments' ], // Initially no defined endpoints, Use rtk query's injectEndpoints endpoints: () => ({}) diff --git a/src/client/app/redux/api/baselineApi.ts b/src/client/app/redux/api/baselineApi.ts new file mode 100644 index 0000000000..55c09ce05c --- /dev/null +++ b/src/client/app/redux/api/baselineApi.ts @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createSelector } from '@reduxjs/toolkit'; +import { + Baseline, + BaselineSegment, + CreateBaselinePayload, + SplitBaselineSegmentPayload, + UpdateBaselineSegmentPayload +} from '../../types/redux/baselines'; +import { baseApi } from './baseApi'; +// export const unitsAdapter = createEntityAdapter({ +// selectId +// // sortComparer: (unitA, unitB) => unitA.identifier?.localeCompare(unitB.identifier, undefined, { sensitivity: 'accent' }) +// }); +// export const unitsInitialState = unitsAdapter.getInitialState(); +// export type UnitDataState = EntityState; + +export const baselineApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getBaselinesDetails: builder.query({ + query: () => 'api/baseline', + providesTags: ['Baselines'] + }), + getBaselineByMeterId: builder.query({ + query: meterId => `api/baseline/${meterId}`, + providesTags: (result, error, id) => [{ type: 'Baselines', id }] + }), + addBaseline: builder.mutation({ + query: baseline => ({ + url: 'api/baseline/new', + method: 'POST', + body: baseline + }), + transformErrorResponse: res => res.data, + invalidatesTags: ['Baselines'] + }), + editBaseline: builder.mutation({ + query: baseline => ({ + url: 'api/baseline/edit', + method: 'POST', + body: baseline + }), + // invalidatesTags: ['Baselines'] + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'Baselines', id: arg.meterId }] + }), + deleteBaseline: builder.mutation({ + query: meterId => ({ + url: 'api/baseline/delete', + method: 'POST', + body: { id: meterId } + }), + invalidatesTags: ['Baselines'] + }) + }) +}); + +export const baselineSegmentsApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getBaselineSegmentsByMeterId: builder.query({ + query: meterId => `api/baselineSegments/${meterId}`, + providesTags: (result, error, id) => [{ type: 'BaselineSegments', id }] + }), + addBaselineSegment: builder.mutation>({ + query: baselineSegment => ({ + url: 'api/baselineSegments/addBaselineSegment', + method: 'POST', + body: baselineSegment + }), + transformErrorResponse: res => res.data, + invalidatesTags: ['BaselineSegments'] + }), + // TO-DO: split functions + splitEarlier: builder.mutation>({ + query: ({ id, newBaselineValue, newNote, splitTime }) => ({ + url: 'api/baselineSegments/splitEarlier', + method: 'POST', + body: { id, newBaselineValue, newNote, splitTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }), + splitLater: builder.mutation>({ + query: ({ id, newBaselineValue, newNote, splitTime }) => ({ + url: 'api/baselineSegments/splitLater', + method: 'POST', + body: { id, newBaselineValue, newNote, splitTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }), + editBaselineSegment: builder.mutation({ + query: baselineSegment => ({ + url: 'api/baselineSegments/edit', + method: 'POST', + body: baselineSegment + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }), + deleteBaselineSegment: builder.mutation({ + query: ({ meterId, startTime, endTime }) => ({ + url: 'api/baselineSegments/delete', + method: 'POST', + body: { meterId, startTime, endTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }), + // Deletes the provided day segment and updates the end time of the previous segment + deleteBaselineSegmentEarlier: builder.mutation({ + query: ({ meterId, startTime, endTime }) => ({ + url: 'api/baselineSegments/deleteEarlier', + method: 'POST', + body: { meterId, startTime, endTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }), + // Deletes the provided day segment and updates the start time of the next segment + deleteBaselineSegmentLater: builder.mutation({ + query: ({ meterId, startTime, endTime }) => ({ + url: 'api/baselineSegments/deleteLater', + method: 'POST', + body: { meterId, startTime, endTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'BaselineSegments', meterId: arg.meterId }] + }) + }) +}); + +export const selectBaselinesQueryState = baselineApi.endpoints.getBaselinesDetails.select(); +export const selectBaselinesDetails= createSelector( + selectBaselinesQueryState, + ({ data: baselineData = [] }) => { + return baselineData; + } +); + +export const { + useGetBaselinesDetailsQuery, + useGetBaselineByMeterIdQuery, + useAddBaselineMutation, + useEditBaselineMutation, + useDeleteBaselineMutation +} = baselineApi; + + +export const { + useGetBaselineSegmentsByMeterIdQuery, + useAddBaselineSegmentMutation, + useEditBaselineSegmentMutation, + useDeleteBaselineSegmentMutation +} = baselineSegmentsApi; \ No newline at end of file diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index a17436adb7..41af53bcae 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -2,10 +2,11 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { createSelector } from '@reduxjs/toolkit'; import * as moment from 'moment'; import { selectCik, selectConversionsDetails } from '../../redux/api/conversionsApi'; import { selectAllGroups } from '../../redux/api/groupsApi'; -import { selectAllMeters, selectMeterById } from '../../redux/api/metersApi'; +import { selectAllMeters, selectMeterById, selectMeterDataById } from '../../redux/api/metersApi'; import { selectAdminPreferences } from '../../redux/slices/adminSlice'; import { ConversionData } from '../../types/redux/conversions'; import { MeterData, MeterTimeSortType } from '../../types/redux/meters'; @@ -20,6 +21,9 @@ import { createAppSelector } from './selectors'; import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { DisableChecksType } from '../../types/redux/units'; import { MAX_VAL, MIN_VAL } from '../../utils/input'; +import { Baseline, BaselineSegment, SplitBaselineSegmentPayload } from 'types/redux/baselines'; +import { selectBaselinesDetails } from '../api/baselineApi'; +import { create } from 'lodash'; export const MIN_DATE_MOMENT = moment(0).utc(); export const MAX_DATE_MOMENT = moment(0).utc().add(5000, 'years'); @@ -339,6 +343,60 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); +const selectBaselineSegmentById = (baselineSegment: BaselineSegment) => baselineSegment.id; +export const selectDefaultSplitBaselineSegmentValues = createSelector( + [selectBaselineSegmentById], + (id: number) => ({ + id, + newBaselineValue: 0, + newNote: '', + splitTime: -999 + } as SplitBaselineSegmentPayload) +); + +export const selectDefaultCreateBaselineValues = createAppSelector( + [ selectMeterById ], + (meter) => { + const defaultValues = { + isActive: true, + // Baseline note + note: '', + baselineValue: 0, + // First segment note + segmentNote: '', + startTime: -Infinity, + endTime: Infinity + }; + return defaultValues; + } +); + +export const selectBaselineExists = createAppSelector( + [ + selectMeterDataById, + selectBaselinesDetails, + (_state, meterId: number) => meterId + // (_state, baselineState: Baseline) => baselineState.baselineValue, + // (_state, baselineState: Baseline) => baselineState.isActive + ], + (meterDataById, baselines): [boolean, string, Baseline] => { + // Find baseline with the matching meter ID + const baselineIndex = baselines.findIndex((baseline: Baseline) => (baseline.meterId === meterDataById[baseline.meterId].id)); + if (baselineIndex !== -1) { + const baseline = baselines[baselineIndex]; + + // TODO: internationalize + if (baseline.isActive) { + return [true, 'Baseline exists and is active', baseline]; + } else { + return [true, 'Baseline exists but is not active', baseline]; + } + } + // Return empty baseline if not exists + return [false, 'Baseline does not exist', {} as Baseline]; + } +); + /* Create Meter Validation: Name cannot be blank Area must be positive or zero diff --git a/src/client/app/styles/modal.css b/src/client/app/styles/modal.css index 64d8c4813c..29405c6c18 100644 --- a/src/client/app/styles/modal.css +++ b/src/client/app/styles/modal.css @@ -21,4 +21,8 @@ select option[value="true"] { .confirmation-message { white-space: pre-wrap; +} + +.baseline-btn { + text-decoration: underline; } \ No newline at end of file diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 60490fb536..94da3bc7a0 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -40,6 +40,22 @@ const LocaleTranslationData = { "bar.interval": "Bar Interval", "bar.raw": "Cannot create bar graph on raw units such as temperature", "bar.stacking": "Bar Stacking", + "baseline.active": "Baseline is active", + "baseline.create": "Create Baseline", + "baseline.create.inactive": "Baseline is inactive", + "baseline.create.exists": "Baseline already exists", + "baseline.delete.success": "Baseline deleted successfully", + "baseline.delete.error": "Error deleting baseline", + "baseline.delete.button": "Delete Baseline", + "baseline.delete.confirm": "Are you sure you want to delete this baseline?", + "baseline.edit.success": "Baseline edited successfully", + "baseline.edit.error": "Error editing baseline", + "baseline.initial.segment": "Baseline Segment", + "baseline.segment.note": "Segment note:", + "baseline.value": "Baseline value:", + "baseline.value.zero": "Baseline value is currently zero", + "baseline.start.time": "Start Time:", + "baseline.end.time": "End Time:", "BooleanMeterTypes.false": "no", "BooleanMeterTypes.meter": "meter or default value", "BooleanMeterTypes.true": "yes", @@ -610,6 +626,22 @@ const LocaleTranslationData = { "bar.interval": "Intervalle du Diagramme à Bandes", "bar.raw": "Cannot create bar graph on raw units such as temperature\u{26A1}", "bar.stacking": "Empilage de Bandes", + "baseline.active": "Baseline is active\u{26A1}", + "baseline.create": "Create Baseline\u{26A1}", + "baseline.create.inactive": "Baseline is inactive\u{26A1}", + "baseline.create.exists": "Baseline already exists\u{26A1}", + "baseline.delete.success": "Baseline deleted successfully\u{26A1}", + "baseline.delete.error": "Error deleting baseline\u{26A1}", + "baseline.delete.button": "Delete Baseline\u{26A1}", + "baseline.delete.confirm": "Are you sure you want to delete this baseline?\u{26A1}", + "baseline.edit.success": "Baseline edited successfully\u{26A1}", + "baseline.edit.error": "Error editing baseline\u{26A1}", + "baseline.initial.segment": "Baseline Segment\u{26A1}", + "baseline.segment.note": "Segment note:\u{26A1}", + "baseline.start.time": "Start Time:\u{26A1}", + "baseline.end.time": "End Time:\u{26A1}", + "baseline.value": "Baseline value:\u{26A1}", + "baseline.value.zero": "Baseline value is currently zero\u{26A1}", "BooleanMeterTypes.false": "yes\u{26A1}", "BooleanMeterTypes.meter": "valeur du compteur ou valeur par défaut", "BooleanMeterTypes.true": "no\u{26A1}", @@ -1180,6 +1212,19 @@ const LocaleTranslationData = { "bar.interval": "Intervalo de barra", "bar.raw": "No se puede crear un gráfico de barras con unidades crudas como la temperatura", "bar.stacking": "Apilamiento de barras", + "baseline.active": "Baseline is active\u{26A1}", + "baseline.create": "Create Baseline\u{26A1}", + "baseline.create.inactive": "Baseline is inactive\u{26A1}", + "baseline.create.exists": "Baseline already exists\u{26A1}", + "baseline.delete.success": "Baseline deleted successfully\u{26A1}", + "baseline.delete.error": "Error deleting baseline\u{26A1}", + "baseline.delete.button": "Delete Baseline\u{26A1}", + "baseline.delete.confirm": "Are you sure you want to delete this baseline?\u{26A1}", + "baseline.edit.success": "Baseline edited successfully\u{26A1}", + "baseline.edit.error": "Error editing baseline\u{26A1}", + "baseline.start.time": "Start Time:\u{26A1}", + "baseline.end.time": "End Time:\u{26A1}", + "baseline.value.zero": "Baseline value is currently zero\u{26A1}", "BooleanMeterTypes.false": "no", "BooleanMeterTypes.meter": "medidor o valor predeterminado", "BooleanMeterTypes.true": "sí", diff --git a/src/client/app/types/redux/baselines.ts b/src/client/app/types/redux/baselines.ts new file mode 100644 index 0000000000..97bbf7ad62 --- /dev/null +++ b/src/client/app/types/redux/baselines.ts @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export interface Baseline { + meterId: number; + // Whether there's a baseline applied or not + isActive: boolean; + note?: string; +} + +export interface BaselineSegment { + id: number; + meterId: number; + // Calc range can be null if the value is manually enter + calcStart?: number; + calcEnd?: number; + startTime: number; + endTime: number; + baselineValue: number; + note?: string; +} + +export interface UpdateBaselineSegmentPayload extends BaselineSegment { + originalStartHour: number; + originalEndHour: number; +} + +export interface CreateBaselinePayload { + meterId: number; + isActive: boolean; + // Baseline note + note?: string; + baselineValue: number; + calcStart?: number | undefined; + calcEnd?: number | undefined; + // First segment note + segmentNote?: string; +} + +export interface SplitBaselineSegmentPayload { + id: number; + meterId: number; + newBaselineValue: number; + newNote?: string; + splitTime: number; +} diff --git a/src/server/app.js b/src/server/app.js index 79385ade76..1d8e538b6b 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -24,6 +24,7 @@ const version = require('./routes/version'); const createRouterForReadings = require('./routes/unitReadings').createRouter; const createRouterForCompareReadings = require('./routes/compareReadings').createRouter; const baseline = require('./routes/baseline'); +const baselineSegments = require('./routes/baselineSegments'); const maps = require('./routes/maps'); const logs = require('./routes/logs'); const obvius = require('./routes/obvius'); @@ -132,6 +133,7 @@ app.use('/api/version', version); app.use('/api/unitReadings', createRouterForReadings()); app.use('/api/compareReadings', createRouterForCompareReadings()); app.use('/api/baselines', baseline); +app.use('/api/baselineSegments', baselineSegments); app.use('/api/maps', maps); app.use('/api/logs', logs); app.use('/api/obvius', obvius); diff --git a/src/server/data/mockBaselines.js b/src/server/data/mockBaselines.js new file mode 100644 index 0000000000..3bd6bbc3c1 --- /dev/null +++ b/src/server/data/mockBaselines.js @@ -0,0 +1,36 @@ +const mockBaselines = [ + { + id: 1, + meterId: 101, + startTime: 0, + endTime: 6, + baselineValue: 12.5, + note: "Night segment" + }, + { + id: 2, + meterId: 101, + startTime: 6, + endTime: 12, + baselineValue: 15.2, + note: "Morning segment" + }, + { + id: 3, + meterId: 101, + startTime: 12, + endTime: 18, + baselineValue: 18.0, + note: "Afternoon segment" + }, + { + id: 4, + meterId: 101, + startTime: 18, + endTime: 24, + baselineValue: 14.3, + note: "Evening segment" + } +]; + +module.exports = mockBaselines; \ No newline at end of file diff --git a/src/server/models/Baseline.js b/src/server/models/Baseline.js index de4de54b01..e2d208463a 100644 --- a/src/server/models/Baseline.js +++ b/src/server/models/Baseline.js @@ -70,6 +70,15 @@ class Baseline { const rows = await conn.any(sqlFile('baseline/get_all_baselines.sql')); return rows.map(row => Baseline.mapRow(row)); } + + /** + * Updates an existed baseline in the database. + * @param {*} conn The connection to use. + */ + async update(conn) { + const baseline = this; + await conn.none(sqlFile('baseline/update_baseline.sql'), baseline); + } } module.exports = Baseline; diff --git a/src/server/models/BaselineSegment.js b/src/server/models/BaselineSegment.js new file mode 100644 index 0000000000..de65fab50a --- /dev/null +++ b/src/server/models/BaselineSegment.js @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +const database = require('./database'); +const { TimeInterval } = require('../../common/TimeInterval'); +const { momentToIsoOrInfinity } = require('../util/handleTimestampValues'); +const sqlFile = database.sqlFile; + +class BaselineSegment { + /** + * @param {*} meterId The foreign key to the corresponding baseline/meter. + * @param {*} baselineValue The baseline value within the segment. + * @param {*} startTime The moment the segment starts. + * @param {*} endTime The moment the segment ends. + * @param {*} calcStart The moment the segment starts being calculated. + * @param {*} calcEnd The moment the segment stops being calculated. + * @param {*} note Comments by the admin or OED inserted. + */ + constructor(meterId, baselineValue, startTime, endTime, calcStart, calcEnd, note) { + this.meterId = meterId; + this.baselineValue = baselineValue; + this.startTime = momentToIsoOrInfinity(startTime); + this.endTime = momentToIsoOrInfinity(endTime); + this.calcStart = momentToIsoOrInfinity(calcStart); + this.calcEnd = momentToIsoOrInfinity(calcEnd); + this.note = note; + } + + /** + * Returns a promise to create the baseline_segments table. + * @param {*} conn The connection to use. + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('baselineSegment/create_baseline_segments_table.sql')); + } + + /** + * Creates a new baseline segment from the data in a row. + * @param {*} row The row from which the baseline segment will be created. + * @returns BaselineSegment + */ + static mapRow(row) { + return new BaselineSegment( + row.meter_id, + row.baseline_value, + row.start_time, + row.end_time, + row.calc_start, + row.calc_end, + row.note); + } + + /** + * Get all baseline segments of the baseline/meter in the database. + * @param {*} conn The connection to use. + * @returns {Promise.>} + */ + static async getAllByMeterId(meterId, conn) { + const rows = await conn.many(sqlFile('baselineSegment/get_all_by_meter_id.sql'), meterId); + return rows.map(BaselineSegment.mapRow); + } + + /** + * Updates an existed baseline segment in the database. + * Note: This function only supports updates where the new start and/or end time extends into the immediately adjacent segments. + * @param {*} originalStartTime The original start time of the segment being updated. + * @param {*} originalEndTime The original end time of the segment being updated. + * @param {*} conn The connection to use. + */ + async update(originalStartTime, originalEndTime, conn) { + const baselineSegment = { + ...this, + originalStartTime, + originalEndTime + }; + const startChanged = this.startTime !== originalStartTime + const endChanged = this.endTime !== originalEndTime; + + // check that -infinity and infinity aren't being updated + if ((startChanged && (originalStartTime === '-infinity')) || endChanged && (originalEndTime === 'infinity')) { + const errMsg = `Cannot update starting time of -infinity or ending time of infinity`; + log.error(errMsg); + throw new Error(errMsg); + } + + return conn.tx(async t => { + // update the previous segment's end time to the updated start time + if (startChanged) { + await t.none(sqlFile('baselineSegment/update_prev_seg_end_to_new_start.sql'), BaselineSegment); + } + + // update the next segment's start time to the updated end time + if (endChanged) { + await t.none(sqlFile('baselineSegment/update_next_seg_start_to_new_end.sql'), baselineSegment); + } + + // Update the current segment + await t.none(sqlFile('baselineSegment/update_baseline_segment.sql'), baselineSegment); + }); + } + + static async splitEarlier(meterId, newBaselineValue, newNote, originalStartTime, originalEndTime, splitTime, conn) { + return conn.tx(async t => { + // get all data for the original segment + const originalSegment = await t.none(sqlFile('baselineSegment/get_by_meter_id_start_end.sql'), { + meterId: meterId, + startTime: originalStartTime, + endTime: originalEndTime + }); + + // earlier segment - insert new + const earlierSegment = { + meterId: originalSegment.meterId, + baselineValue: newBaselineValue, + startTime: originalSegment.start_time, + endTime: splitTime, + note: newNote + } + await t.none(sqlFile('baselineSegment/insert_new_baseline_segment.sql'), earlierSegment); + + // later segment - update start time + await t.none(sqlFile('baselineSegment/update_baseline_segment.sql'), { + meterId: originalSegment.meterId, + baselineValue: originalSegment.baselineValue, + startTime: splitTime, + endTime: originalSegment.end_time, + note: originalSegment.note, + originalStartTime: originalSegment.start_time, + originalEndTime: originalSegment.end_time + }); + }); + } + + /** + * Split a segment in two, the later segment uses the new baselineValue/note + * @param {*} meterId The meter's id. + * @param {*} newBaselineValue The baseline value for the new segment. + * @param {*} newNote The note for the new segment. + * @param {*} startTime When the current segment starts. + * @param {*} endTime When the current segment ends. + * @param {*} splitTime The time to split the segment at. + * @param {*} conn The connection to use + * @returns {Promise.} + */ + static async splitLater(meterId, newBaselineValue, newNote, originalStartTime, originalEndTime, splitTime, conn) { + return conn.tx(async t => { + // get all data for the original segment + const originalSegment = await t.one(sqlFile('baselineSegment/get_by_meter_id_start_end.sql'), { + meterId: meterId, + startTime: originalStartTime, + endTime: originalEndTime + }); + + // earlier segment - update end time + await t.none(sqlFile('baselineSegment/update_baseline_segment.sql'), { + meterId: originalSegment.meterId, + baselineValue: originalSegment.baselineValue, + startTime: originalSegment.startTime, + endTime: splitTime, + note: originalSegment.note, + originalStartTime: originalSegment.startTime, + originalEndTime: originalSegment.endTime + }); + + // later segment - insert new + const earlierSegment = { + meterId: originalSegment.meterId, + baselineValue: newBaselineValue, + startTime: splitTime, + endTime: originalSegment.endTime, + calcStart: null, + calcEnd: null, + note: newNote + } + await t.none(sqlFile('baselineSegment/insert_new_baseline_segment.sql'), earlierSegment); + }); + } +} + +module.exports = BaselineSegment; \ No newline at end of file diff --git a/src/server/routes/baseline.js b/src/server/routes/baseline.js index 07d85fe8f2..c6322195fa 100644 --- a/src/server/routes/baseline.js +++ b/src/server/routes/baseline.js @@ -7,14 +7,16 @@ const { getConnection } = require('../db'); const express = require('express'); const Baseline = require('../models/Baseline'); const log = require('../log'); +const { adminAuthMiddleware } = require('./authenticator'); const router = express.Router(); + router.get('/', async (req, res) => { const conn = getConnection(); try { const rawBaselines = await Baseline.getAllBaselines(conn); res.json(rawBaselines); } catch (err) { - log(`Error while getting all baselines: ${err}`, 'error'); + log.error(`Error while getting all baselines: ${err}`, 'error'); } }); router.post('/new', async (req, res) => { @@ -34,4 +36,37 @@ router.post('/new', async (req, res) => { log(`Error while adding baseline: ${err}`, 'error'); } }); +router.post('/edit', adminAuthMiddleware('edit baselines'), async (req, res) => { + const validBaseline = { + type: 'object', + required: ['meterId', 'isActive', 'note'], + properties: { + meterId: { type: 'number' }, + isActive: { type: 'boolean' }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + // not edited + const validatorResult = validate(req.body, validBaseline); + if (!validatorResult.valid) { + log.warn(`Got request to edit baselines with invalid baseline data, errors: ${validatorResult.errors}`); + failure(res, 400, `Got request to edit baselines with invalid baseline data, errors: ${validatorResult.errors}`); + } else { + const conn = getConnection(); + try { + const updatedBaseline = new Baseline(req.body.meterId, req.body.isActive, req.body.note); + await updatedBaseline.update(conn); + } catch (err) { + log.error(`Error while editing conversion with error(s): ${err}`); + failure(res, 500, `Error while editing conversion with error(s): ${err}`); + } + success(res); + } +}); + module.exports = router; diff --git a/src/server/routes/baselineSegments.js b/src/server/routes/baselineSegments.js new file mode 100644 index 0000000000..f709d32c3f --- /dev/null +++ b/src/server/routes/baselineSegments.js @@ -0,0 +1,296 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +const { getConnection } = require('../db'); +const express = require('express'); +const BaselineSegment = require('../models/BaselineSegment'); +const log = require('../log'); +const { adminAuthMiddleware } = require('./authenticator'); +const router = express.Router(); +const mockBaselines = require("../data/mockBaselines") //TODO: Get rid of this, make routes query data from db + +router.get("/getAll", (req, res) => { + res.json(mockBaselines); +}); + + + +/** + * GET all baseline segments by meter ID. + * @param {int} meterId The current meter's id. + */ +router.get("/get", async (req, res) => { + const { meterId } = req.params; + try { + const meterId = req.body.meterId; + const conn = getConnection(); + try { + const rows = await BaselineSegment.getAllByMeterId(meterId, conn); + res.json(rows.map(formatBaselineSegmentsForResponse)); + } catch (err) { + log.error(`Error while performing GET conversions details query: ${err}`); + } + res.json(segments); + } catch (err) { + log.error(`Request sent with no or an invalid meterId`); + } +}); + +/** + * POST add baseline segment. + * @param {int} meter The current meter's id. + * @param {number} baselineValue The slope for the baseline segment. + * @param {string} startTime The start time of the baseline segment. + * @param {string} endTime The end time of the baseline segment. + * @param {string | null} calcStart The calculation start time of the baseline segment. + * @param {string | null} calcEnd The calculation end time of the baseline segment. + * @param {string} note Notes added by the admin for the baseline segment. + */ +router.post('/addBaselineSegment', adminAuthMiddleware('add baseline segment'), async (req, res) => { + const validBaselineSegment = { + type: 'object', + maxProperties: 8, + required: ['meterId', 'baselineValue', 'startTime', 'endTime'], + properties: { + meterId: { + type: 'integer', + minimum: 0 + }, + baselineValue: { + type: 'number' + }, + startTime: { + type: 'string' + }, + endTime: { + type: 'string' + }, + calcStart: { + oneOf: [ + {type: 'string'}, + {type: 'null'} + ] + }, + calcEnd: { + oneOf: [ + {type: 'string'}, + {type: 'null'} + ] + }, + note: { + oneOf: [ + {type: 'string'}, + {type: 'null'} + ] + } + } + }; + + const validatorResult = validate(req.body, validBaselineSegment); + if (!validatorResult.valid) { + const errMsg = `Got request to add a baseline segment with invalid baseline segment data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const newBaselineSegment = new BaselineSegment( + req.body.meterId, + req.body.baselineValue, + momentToIsoOrInfinity(req.body.startTime), + momentToIsoOrInfinity(req.body.endTime), + momentToIsoOrInfinity(req.body.calsStart), + momentToIsoOrInfinity(req.body.calcEnd), + req.body.note + ); + await newBaselineSegment.insert(conn); + success(res, `Successfully added baseline segment`); + } catch (err) { + const errMsg = `Error adding baseline segment with error(s): ${err}` + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +router.post('/edit', adminAuthMiddleware('edit baseline segments'), async (req, res) => { + const validBaselineSegment = { + type: 'object', + required: ['meterId', 'baselineValue', 'startTime', 'endTime', 'calcStart', 'calcEnd', 'note'], + properties: { + meterId: { type: 'number' }, + baselineValue: { + type: 'number', + minimum: 0 + }, + startTime: { type: 'number' }, + endTime: { type: 'number' }, + calcStart: { + oneOf: [ + { type: 'number' }, + { type: 'null' } + ] + }, + calcEnd: { + oneOf: [ + { type: 'number' }, + { type: 'null' } + ] + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + const validatorResult = validate(req.body, validBaselineSegment); + if (!validatorResult.valid) { + log.warn(`Got request to edit baselines with invalid baseline segment data, errors: ${validatorResult.errors}`); + failure(res, 400, `Got request to edit baselines with invalid baseline segment data, errors: ${validatorResult.errors}`); + } else { + const conn = getConnection(); + try { + const updatedBaselineSegment = new BaselineSegment(req.body.meterId, req.body.isActive, req.body.note); + await updatedBaseline.update(conn); + } catch (err) { + log.error(`Error while editing conversion with error(s): ${err}`); + failure(res, 500, `Error while editing conversion with error(s): ${err}`); + } + success(res); + } +}); +/** + * POST split a segment in two, the earlier segment uses the new baselineValue/note. + * @param {int} meterId The meter's id. + * @param {string} startTime The start time of the baseline segment. + * @param {string} endTime The end time of the baseline segment. + * @param {number} newBaselineValue The baseline value for the new baseline segment. + * @param {string} newNote Notes added by the admin for the new baseline segment. + * @param {string} splitTime The time to split the segment at. + */ +router.post('/splitEarlier', adminAuthMiddleware('split earlier baseline segment'), async (req, res) => { + const validBaselineSegment = { + type: 'object', + maxProperties: 9, + required: ['meterId', 'startTime', 'endTime', 'newBaselineValue', 'newNote', 'splitTime'], + properties: { + meterId: { + type: 'integer', + minimum: 0 + }, + startTime: { + type: 'string' + }, + endTime: { + type: 'string' + }, + newBaselineValue: { + type: 'number' + }, + newNote: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + }, + splitTime: { + type: 'string' + } + } + }; + + const validatorResult = validate(req.body, validBaselineSegment); + if (!validatorResult.valid) { + const errMsg = `Got request to split a baseline segment earlier with invalid baseline segment data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await BaselineSegment.splitEarlier( + req.body.meterId, + req.body.newBaselineValue, + req.body.newNote, + momentToIsoOrInfinity(req.body.startTime), + momentToIsoOrInfinity(req.body.endTime), + momentToIsoOrInfinity(req.body.splitTime), + conn + ); + success(res, `Successfully split baseline segment earlier`); + } catch (err) { + const errMsg = `Error splitting baseline segment earlier with error(s): ${err}` + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * POST split a segment in two, the later segment uses the new baselineValue/note. + * @param {int} meterId The meter's id. + * @param {string} startTime The start time of the baseline segment. + * @param {string} endTime The end time of the baseline segment. + * @param {number} newBaselineValue The baseline value for the new baseline segment. + * @param {string} newNote Notes added by the admin for the new baseline segment. + * @param {string} splitTime The time to split the segment at. +*/ +router.post('/splitLater', adminAuthMiddleware('split later baseline segment'), async (req, res) => { + const validBaselineSegment = { + type: 'object', + maxProperties: 9, + required: ['meterId', 'startTime', 'endTime', 'newBaselineValue', 'newNote', 'splitTime'], + properties: { + meterId: { + type: 'integer', + minimum: 0 + }, + startTime: { + type: 'string' + }, + endTime: { + type: 'string' + }, + newBaselineValue: { + type: 'number' + }, + newNote: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + }, + splitTime: { + type: 'string' + } + } + }; + + const validatorResult = validate(req.body, validBaselineSegment); + if (!validatorResult.valid) { + const errMsg = `Got request to split a baseline segment later with invalid baseline segment data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await BaselineSegment.splitLater( + req.body.meterId, + req.body.newBaselineValue, + req.body.newNote, + momentToIsoOrInfinity(req.body.startTime), + momentToIsoOrInfinity(req.body.endTime), + momentToIsoOrInfinity(req.body.splitTime), + conn + ); + success(res, `Successfully split baseline segment later`); + } catch (err) { + const errMsg = `Error splitting baseline segment later with error(s): ${err}` + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); \ No newline at end of file diff --git a/src/server/routes/conversionSegments.js b/src/server/routes/conversionSegments.js index 63346911dd..0b2fb60179 100644 --- a/src/server/routes/conversionSegments.js +++ b/src/server/routes/conversionSegments.js @@ -31,6 +31,7 @@ function formatConversionSegmentForResponse(item) { * @param {int} sourceId The source meter's id. * @param {int} destinationId The destination meter's id. */ +// This should be a get router.post('/sourceDestination', adminAuthMiddleware('get conversion segment(s) by source and destination id'), async (req, res) => { const validConversionSegment = { type: 'object', @@ -77,6 +78,7 @@ router.post('/sourceDestination', adminAuthMiddleware('get conversion segment(s) * @param {string} startTime The start time of the conversion segment. * @param {string} endTime The end time of the conversion segment. */ +// This should be a get router.post('/sourceDestinationStartEnd', adminAuthMiddleware('get conversion segment by source id, destination id, start time, and end time'), async (req, res) => { const validConversionSegment = { type: 'object', diff --git a/src/server/sql/baseline/update_baseline.sql b/src/server/sql/baseline/update_baseline.sql new file mode 100644 index 0000000000..ce2c8a246a --- /dev/null +++ b/src/server/sql/baseline/update_baseline.sql @@ -0,0 +1,11 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +-- Update overall baseline info using meter ID +UPDATE baselines + SET + is_active = ${isActive}, + note = ${note} + WHERE meter_id = ${meterId}; \ No newline at end of file diff --git a/src/server/sql/baselineSegment/create_baseline_segments_table.sql b/src/server/sql/baselineSegment/create_baseline_segments_table.sql new file mode 100644 index 0000000000..5d87025824 --- /dev/null +++ b/src/server/sql/baselineSegment/create_baseline_segments_table.sql @@ -0,0 +1,16 @@ + +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +CREATE TABLE IF NOT EXISTS baseline_segments ( + meter_id INTEGER NOT NULL REFERENCES meters(id), + baseline_value FLOAT, + start_time TIMESTAMP NOT NULL DEFAULT '-infinity', + end_time TIMESTAMP NOT NULL DEFAULT 'infinity', + calc_start TIMESTAMP, + calc_end TIMESTAMP, + note TEXT, + PRIMARY KEY (meter_id, start_time, end_time), + CHECK (start_time < end_time) +); \ No newline at end of file diff --git a/src/server/sql/baselineSegment/get_all_by_meter_id.sql b/src/server/sql/baselineSegment/get_all_by_meter_id.sql new file mode 100644 index 0000000000..1c04ed87d6 --- /dev/null +++ b/src/server/sql/baselineSegment/get_all_by_meter_id.sql @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +-- Select all baseline segments with corresponding meter ID +SELECT + meter_id, + baseline_value, + -- Cast to text to preserve '-infinity' and 'infinity values, otherwise, JavaScript will convert these values to null + start_time::TEXT AS start_time, + end_time::TEXT AS end_time, + calc_start, + calc_end, + note +FROM baseline_segments WHERE meter_id = ${meterId} +ORDER BY start_time::TIMESTAMP ASC; \ No newline at end of file diff --git a/src/server/sql/baselineSegment/get_by_meter_id_start_end.sql b/src/server/sql/baselineSegment/get_by_meter_id_start_end.sql new file mode 100644 index 0000000000..be5734536d --- /dev/null +++ b/src/server/sql/baselineSegment/get_by_meter_id_start_end.sql @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +-- Select the baseline segment with corresponding meter ID, start time and end time +SELECT + meter_id, + baseline_value, + -- Cast to text to preserve '-infinity' and 'infinity values, otherwise, JavaScript will convert these values to null + start_time::TEXT AS start_time, + end_time::TEXT AS end_time, + calc_start, + calc_end, + note +FROM baseline_segments +WHERE meter_id = ${meterId} + AND start_time = ${startTime} + AND end_time = ${endTime}; \ No newline at end of file diff --git a/src/server/sql/baselineSegment/insert_new_baseline_segment.sql b/src/server/sql/baselineSegment/insert_new_baseline_segment.sql new file mode 100644 index 0000000000..b6bf82a807 --- /dev/null +++ b/src/server/sql/baselineSegment/insert_new_baseline_segment.sql @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +INSERT INTO baseline_segments(meter_id, baseline_value, start_time, end_time, calc_start, calc_end, note) +VALUES (${meterId}, ${baselineValue}, ${startTime}, ${endTime}, ${calcStart}, ${calcEnd}, ${note}); \ No newline at end of file diff --git a/src/server/sql/baselineSegment/update_baseline_segment.sql b/src/server/sql/baselineSegment/update_baseline_segment.sql new file mode 100644 index 0000000000..9c3c54217e --- /dev/null +++ b/src/server/sql/baselineSegment/update_baseline_segment.sql @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +UPDATE baseline_segments + SET baseline_value = ${baselineValue}, + start_time = ${startTime}, + end_time = ${endTime}, + calc_start = ${calcStart}, + calc_end = ${calcEnd}, + note = ${note} + WHERE meter_id = ${meterId} + AND start_time = ${originalStartTime}::TIMESTAMP + AND end_time = ${originalEndTime}::TIMESTAMP; \ No newline at end of file