diff --git a/package-lock.json b/package-lock.json index 68bc1cd28f..32e40207d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "csv": "~5.3.2", "csv-stringify": "~5.6.5", "d3": "~7.8.5", + "date-holidays": "~3.33.0", "dotenv": "~16.4.5", "escape-html": "~1.0.3", "express": "~4.19.2", @@ -1983,8 +1984,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-bounds": { "version": "1.0.1", @@ -2085,6 +2085,15 @@ "node": "*" } }, + "node_modules/astronomia": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/astronomia/-/astronomia-4.2.0.tgz", + "integrity": "sha512-mTvpBGyXB80aSsDhAAiuwza5VqAyqmj5yzhjBrFhRy17DcWDzJrb8Vdl4Sm+g276S+mY7bk/5hi6akZ5RQFeHg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2539,6 +2548,18 @@ "node": ">= 0.8" } }, + "node_modules/caldate": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/caldate/-/caldate-2.0.5.tgz", + "integrity": "sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==", + "license": "ISC", + "dependencies": { + "moment-timezone": "^0.5.43" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -3791,6 +3812,101 @@ "node": ">=12" } }, + "node_modules/date-bengali-revised": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/date-bengali-revised/-/date-bengali-revised-2.0.2.tgz", + "integrity": "sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-chinese": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/date-chinese/-/date-chinese-2.1.4.tgz", + "integrity": "sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==", + "license": "MIT", + "dependencies": { + "astronomia": "^4.1.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-easter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/date-easter/-/date-easter-1.0.3.tgz", + "integrity": "sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-holidays": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/date-holidays/-/date-holidays-3.33.0.tgz", + "integrity": "sha512-1pqbC8hOYlWh6K1XxBvR7uZa+EqUauudJah2SIhPNet7wh8DJGvVY1AziUUpq0+pqhxLW04Jobj6sYer0/6oLw==", + "license": "(ISC AND CC-BY-3.0)", + "dependencies": { + "date-holidays-parser": "^3.4.7", + "js-yaml": "^5.2.1", + "lodash": "^4.18.1", + "prepin": "^1.0.3" + }, + "bin": { + "holidays2json": "scripts/holidays2json.cjs" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-holidays-parser": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/date-holidays-parser/-/date-holidays-parser-3.4.7.tgz", + "integrity": "sha512-h09ZEtM6u5cYM6m1bX+1Ny9f+nLO9KVZUKNPEnH7lhbXYTfqZogaGTnhONswGeIJFF91UImIftS3CdM9HLW5oQ==", + "license": "ISC", + "dependencies": { + "astronomia": "^4.1.1", + "caldate": "^2.0.5", + "date-bengali-revised": "^2.0.2", + "date-chinese": "^2.1.4", + "date-easter": "^1.0.3", + "deepmerge": "^4.3.1", + "jalaali-js": "^1.2.7", + "moment-timezone": "^0.5.47" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/date-holidays/node_modules/js-yaml": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/date-holidays/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3845,6 +3961,15 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -6268,6 +6393,12 @@ "node": ">=0.10.0" } }, + "node_modules/jalaali-js": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz", + "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==", + "license": "MIT" + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -7048,9 +7179,10 @@ } }, "node_modules/moment-timezone": { - "version": "0.5.45", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", - "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", "dependencies": { "moment": "^2.29.4" }, @@ -8168,6 +8300,15 @@ "node": ">= 0.8.0" } }, + "node_modules/prepin": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/prepin/-/prepin-1.0.3.tgz", + "integrity": "sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==", + "license": "Unlicense", + "bin": { + "prepin": "bin/prepin.js" + } + }, "node_modules/probe-image-size": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", diff --git a/package.json b/package.json index d7c627eec1..7d6ced8ebe 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "csv": "~5.3.2", "csv-stringify": "~5.6.5", "d3": "~7.8.5", + "date-holidays": "~3.33.0", "dotenv": "~16.4.5", "escape-html": "~1.0.3", "express": "~4.19.2", diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index 8aa3fe2fb3..d10e08fc71 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -11,15 +11,15 @@ import TooltipHelpComponent from '../components/TooltipHelpComponent'; import { clearGraphHistory } from '../redux/actions/extraActions'; import { authApi } from '../redux/api/authApi'; import { selectOEDVersion } from '../redux/api/versionApi'; +import { useTranslate } from '../redux/componentHooks'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectHelpUrl } from '../redux/slices/adminSlice'; import { selectOptionsVisibility, toggleOptionsVisibility } from '../redux/slices/appStateSlice'; import { selectHasRolePermissions, selectIsAdmin, selectIsLoggedIn } from '../redux/slices/currentUserSlice'; import { UserRole } from '../types/items'; -import { useTranslate } from '../redux/componentHooks'; import LanguageSelectorComponent from './LanguageSelectorComponent'; -import TooltipMarkerComponent from './TooltipMarkerComponent'; import LoginComponent from './LoginComponent'; +import TooltipMarkerComponent from './TooltipMarkerComponent'; /** * React Component that defines the header buttons at the top of a page @@ -58,12 +58,17 @@ export default function HeaderButtonsComponent() { shouldAdminButtonDisabled: true, shouldUsersButtonDisabled: true, shouldGroupsButtonDisabled: true, + shouldHolidaysButtonDisabled: true, + shouldHolidayInstancesButtonDisabled: true, + shouldHolidayInstanceGroupsButtonDisabled: true, shouldMetersButtonDisabled: true, shouldMapsButtonDisabled: true, shouldCSVMetersButtonDisabled: true, shouldCSVReadingsButtonDisabled: true, shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, + shouldDaysButtonDisabled: true, + shouldWeeksButtonDisabled: true, shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, // Translated menu title that depend on whether logged in. @@ -96,12 +101,17 @@ export default function HeaderButtonsComponent() { shouldAdminButtonDisabled: pathname === '/admin', shouldUsersButtonDisabled: pathname === '/users', shouldGroupsButtonDisabled: pathname === '/groups', + shouldHolidaysButtonDisabled: pathname === '/holidays', + shouldHolidayInstancesButtonDisabled: pathname === '/holiday-instances', + shouldHolidayInstanceGroupsButtonDisabled: pathname === '/holiday-instance-groups', shouldMetersButtonDisabled: pathname === '/meters', shouldMapsButtonDisabled: pathname === '/maps', shouldCSVMetersButtonDisabled: pathname === '/csvMeters', shouldCSVReadingsButtonDisabled: pathname === '/csvReadings', shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', + shouldDaysButtonDisabled: pathname === '/days', + shouldWeeksButtonDisabled: pathname === '/weeks', shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' })); @@ -211,6 +221,27 @@ export default function HeaderButtonsComponent() { to="/groups"> + + + + + + + + + + + + + + + ); -} \ No newline at end of file +} diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index 15d46daf46..34ae34476d 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -4,29 +4,35 @@ import * as React from 'react'; import { IntlProvider } from 'react-intl'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import ReadingsCSVUploadComponent from '../components/csv/ReadingsCSVUploadComponent'; import MetersCSVUploadComponent from '../components/csv/MetersCSVUploadComponent'; +import ReadingsCSVUploadComponent from '../components/csv/ReadingsCSVUploadComponent'; import MapCalibrationContainer from '../containers/maps/MapCalibrationContainer'; import MapsDetailContainer from '../containers/maps/MapsDetailContainer'; import { useAppSelector } from '../redux/reduxHooks'; +import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; import LocaleTranslationData from '../translations/data'; import { UserRole } from '../types/items'; import AppLayout from './AppLayout'; import HomeComponent from './HomeComponent'; import AdminComponent from './admin/AdminComponent'; +import LogMsgComponent from './admin/LogMsgComponent'; import UsersDetailComponent from './admin/users/UsersDetailComponent'; import ConversionsDetailComponent from './conversion/ConversionsDetailComponent'; +import DaysDetailComponent from './days/DaysDetailComponent'; import GroupsDetailComponent from './groups/GroupsDetailComponent'; import MetersDetailComponent from './meters/MetersDetailComponent'; import AdminOutlet from './router/AdminOutlet'; +import ErrorComponent from './router/ErrorComponent'; import { GraphLink } from './router/GraphLinkComponent'; import NotFound from './router/NotFoundOutlet'; import RoleOutlet from './router/RoleOutlet'; import UnitsDetailComponent from './unit/UnitsDetailComponent'; -import ErrorComponent from './router/ErrorComponent'; -import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; -import LogMsgComponent from './admin/LogMsgComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; +import HolidayInstanceGroupComponent from './holiday-instance-groups/HolidayInstanceGroupComponent'; +import WeeksDetailComponent from './weeks/WeeksDetailComponent'; +import HolidayInstancePage from './holiday-instances/HolidayInstancePage'; +import HolidayPage from './holiday/HolidayPage'; + /** * @returns the router component Responsible for client side routing. @@ -55,15 +61,20 @@ const router = createBrowserRouter([ element: , children: [ { path: 'admin', element: }, + { path: 'holidays', element: }, + { path: 'holiday-instances', element: }, { path: 'calibration', element: }, { path: 'conversions', element: }, + { path: 'days', element: }, + { path: 'weeks', element: }, { path: 'csvMeters', element: }, { path: 'maps', element: }, { path: 'units', element: }, { path: 'users', element: }, { path: 'logmsg', element: }, { path: 'users', element: }, - { path: 'visual-unit', element: } + { path: 'visual-unit', element: }, + { path: 'holiday-instance-groups', element: } ] }, { diff --git a/src/client/app/components/TooltipHelpComponent.tsx b/src/client/app/components/TooltipHelpComponent.tsx index f48de775ea..c3ed0d0c49 100644 --- a/src/client/app/components/TooltipHelpComponent.tsx +++ b/src/client/app/components/TooltipHelpComponent.tsx @@ -6,10 +6,10 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import ReactTooltip from 'react-tooltip'; import { selectOEDVersion } from '../redux/api/versionApi'; +import { useTranslate } from '../redux/componentHooks'; import { useAppSelector } from '../redux/reduxHooks'; import { selectHelpUrl } from '../redux/slices/adminSlice'; import '../styles/tooltip.css'; -import { useTranslate } from '../redux/componentHooks'; interface TooltipHelpProps { page: string; // Specifies which page the tip is in. @@ -32,6 +32,10 @@ export default function TooltipHelpComponent(props: TooltipHelpProps) { 'help.admin.conversioncreate': { link: `${helpUrl}/adminConversionCreating/` }, 'help.admin.conversionedit': { link: `${helpUrl}/adminConversionEditing/` }, 'help.admin.conversionview': { link: `${helpUrl}/adminConversionViewing/` }, + 'help.admin.daycreate': { link: `${helpUrl}` }, // TODO: Add specific link for day creation + 'help.admin.dayedit': { link: `${helpUrl}` }, // TODO: Add specific link for day editing + 'help.admin.dayview': { link: `${helpUrl}` }, // TODO: Add specific link for day viewing + 'help.admin.holidayview': { link: `${helpUrl}` }, // TODO: Add specific link for holiday viewing 'help.admin.groupcreate': { link: `${helpUrl}/adminGroupCreating/` }, 'help.admin.groupedit': { link: `${helpUrl}/adminGroupEditing/` }, 'help.admin.groupview': { link: `${helpUrl}/adminGroupViewing/` }, @@ -44,6 +48,9 @@ export default function TooltipHelpComponent(props: TooltipHelpProps) { 'help.admin.unitedit': { link: `${helpUrl}/adminUnitEditing/` }, 'help.admin.unitview': { link: `${helpUrl}/adminUnitViewing/` }, 'help.admin.users': { link: `${helpUrl}/adminUser/` }, + 'help.admin.weekcreate': { link: `${helpUrl}` }, // TODO: Add specific link for week creation + 'help.admin.weekedit': { link: `${helpUrl}` }, // TODO: Add specific link for week editing + 'help.admin.weekview': { link: `${helpUrl}` }, // TODO: Add specific link for week viewing 'help.csv.meters': { link: `${helpUrl}/adminMetersImport/` }, 'help.csv.readings': { link: `${helpUrl}/adminReadingsImport/` }, 'help.home.area.normalize': { link: `${helpUrl}/areaNormalization/` }, diff --git a/src/client/app/components/admin/LogMsgComponent.tsx b/src/client/app/components/admin/LogMsgComponent.tsx index a84c007477..74c523a2ac 100644 --- a/src/client/app/components/admin/LogMsgComponent.tsx +++ b/src/client/app/components/admin/LogMsgComponent.tsx @@ -20,9 +20,10 @@ import { TimeInterval } from '../../../../common/TimeInterval'; import { dateRangeToTimeInterval, timeIntervalToDateRange } from '../../utils/dateRangeCompatibility'; import { titleStyle } from '../../styles/modalStyle'; import { useTranslate } from '../../redux/componentHooks'; +import { ROWS_PER_TABLE } from '../../utils/pagination'; // number of log messages to display per page -const PER_PAGE = 20; +const PER_PAGE = ROWS_PER_TABLE; enum LogTypes { ERROR = 'ERROR', diff --git a/src/client/app/components/conversion/ConversionViewComponent.tsx b/src/client/app/components/conversion/ConversionViewComponent.tsx index c636423724..c406309f16 100644 --- a/src/client/app/components/conversion/ConversionViewComponent.tsx +++ b/src/client/app/components/conversion/ConversionViewComponent.tsx @@ -60,12 +60,6 @@ export default function ConversionViewComponent(props: ConversionViewComponentPr
{translate(`TrueFalseType.${props.conversion.bidirectional.toString()}`)}
-
- {props.conversion.slope} -
-
- {props.conversion.intercept} -
{/* Only show first 30 characters so card does not get too big. Should limit to one line */} {props.conversion.note.slice(0, 29)} diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index a853c00abe..16f77d17ef 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -2,139 +2,235 @@ * 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 { omit } from 'lodash'; import * as React from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Button, Col, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; -import TooltipHelpComponent from '../TooltipHelpComponent'; import { conversionsApi } from '../../redux/api/conversionsApi'; +import { weeksApi } from '../../redux/api/weeksApi'; +import { useTranslate } from '../../redux/componentHooks'; import { useAppSelector } from '../../redux/reduxHooks'; import { selectDefaultCreateConversionValues, selectIsValidConversion } from '../../redux/selectors/adminSelectors'; import '../../styles/modal.css'; import { tooltipBaseStyle } from '../../styles/modalStyle'; import { TrueFalseType } from '../../types/items'; -import { showErrorNotification } from '../../utils/notifications'; -import { useTranslate } from '../../redux/componentHooks'; -import TooltipMarkerComponent from '../TooltipMarkerComponent'; -import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import { UnitType } from '../../types/redux/units'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; /** * Defines the create conversion modal form * @returns Conversion create element */ export default function CreateConversionModalComponent() { + /* Hooks & Selectors */ const translate = useTranslate(); const [addConversionMutation] = conversionsApi.useAddConversionMutation(); - // Want units in sorted order by identifier regardless of case. - const defaultValues = useAppSelector(selectDefaultCreateConversionValues); + const { data: weeks = [] } = weeksApi.useGetWeeksQuery(); + /* End Hooks & Selectors */ - /* State */ - // Modal show - const [showModal, setShowModal] = useState(false); + /* Utility Functions */ + // Determines whether the selected source is of type meter + const isMeterSource = () => { + const source = defaultValues.sourceOptions.find(u => u.id === conversionState.overallConversion.sourceId); + return source?.typeOfUnit === UnitType.meter; + }; - // State for the warning modal - const [showWarningModal, setShowWarningModal] = useState(false); - const [warningMessage, setWarningMessage] = useState(''); + // Determine whether the selected source or destination is a suffix unit + const isSuffixUsed = () => { + const source = defaultValues.sourceOptions.find(u => u.id === conversionState.overallConversion.sourceId); + const dest = defaultValues.sourceOptions.find(u => u.id === conversionState.overallConversion.destinationId); + return source?.typeOfUnit === UnitType.suffix || dest?.typeOfUnit === UnitType.suffix; + }; - const handleClose = () => { + // Utility to get the initial state + const getInitialConversionState = () => ({ + overallConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + note: defaultValues.overallConversionNote + }, + initialConversion: { + slope: defaultValues.slope, + intercept: defaultValues.intercept, + pattern: defaultValues.weeklyPattern, + segmentNote: defaultValues.initialConversionNote + }, + sourceOptions: defaultValues.sourceOptions, + destinationOptions: defaultValues.destinationOptions, + weeklyPatterns: defaultValues.weeklyPattern + }); + + const resetState = () => { + setConversionState(getInitialConversionState()); + }; + + const submitConversion = () => { + // Close modal first to avoid repeat clicks setShowModal(false); + const weekPatternsId = Number(conversionState.initialConversion.pattern); + const payload = { + sourceId: conversionState.overallConversion.sourceId, + destinationId: conversionState.overallConversion.destinationId, + bidirectional: (isMeterSource() || isSuffixUsed()) ? false : conversionState.overallConversion.bidirectional, + note: conversionState.overallConversion.note, + slope: conversionState.initialConversion.slope, + intercept: conversionState.initialConversion.intercept, + weekPatternsId, + segmentNote: conversionState.initialConversion.segmentNote + }; + addConversionMutation(payload).unwrap() + .then(() => { + showSuccessNotification(translate('conversion.create.success')); + }) + .catch(error => { + showErrorNotification(translate('conversion.create.failure') + error); + }); resetState(); }; - const handleShow = () => setShowModal(true); + /* End Utility Functions */ + + /* State */ + const [showModal, setShowModal] = useState(false); + const [showWarningModal, setShowWarningModal] = useState(false); + const [warningMessage, setWarningMessage] = useState(''); + const [conversionState, setConversionState] = useState(getInitialConversionState()); + const [noPattern, setNoPattern] = useState(conversionState.initialConversion.pattern === -99); + /* End State */ + + /* Effects */ + useEffect(() => { + setNoPattern(conversionState.initialConversion.pattern === -99); + }, [conversionState.initialConversion.pattern]); + /* End Effects */ + + /* Derived State*/ + const [validConversion, reason] = useAppSelector(state => + selectIsValidConversion(state, conversionState) + ); + /* End Derived State */ - // Handlers for each type of input change - const [conversionState, setConversionState] = useState(defaultValues); + /* Handlers */ + const handleOverallNoteChange = (e: React.ChangeEvent) => { + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + note: e.target.value + } + })); + }; - // If the currently selected conversion is valid - const [validConversion, reason] = useAppSelector(state => selectIsValidConversion(state, conversionState)); + const handleInitialNoteChange = (e: React.ChangeEvent) => { + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + segmentNote: e.target.value + } + })); + }; - const handleStringChange = (e: React.ChangeEvent) => { - setConversionState({ ...conversionState, [e.target.name]: e.target.value }); + const handleSourceIdChange = (e: React.ChangeEvent) => { + const newValue = Number(e.target.value); + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + sourceId: newValue + }, + destinationOptions: defaultValues.destinationOptions.filter(destination => destination.id !== newValue) + })); }; - const handleBooleanChange = (e: React.ChangeEvent) => { - setConversionState({ ...conversionState, [e.target.name]: JSON.parse(e.target.value) }); + const handleDestinationIdChange = (e: React.ChangeEvent) => { + const newValue = Number(e.target.value); + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + destinationId: newValue + }, + sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) + })); }; const handleNumberChange = (e: React.ChangeEvent) => { - // once a source or destination is selected, it will be removed from the other options. - if (e.target.name === 'sourceId') { - setConversionState(state => ({ - ...state, - sourceId: Number(e.target.value), - destinationOptions: defaultValues.destinationOptions.filter(destination => destination.id !== Number(e.target.value)) - })); - } else if (e.target.name === 'destinationId') { - setConversionState(state => ({ - ...state, - destinationId: Number(e.target.value), - sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== Number(e.target.value)) - })); - } else { - setConversionState(state => ({ ...state, [e.target.name]: Number(e.target.value) })); - } + const { name, value } = e.target; + const newValue = Number(value); + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + [name]: newValue + } + })); }; - /* End State */ - // Determines whether the selected source is of type meter - const isMeterSource = () => { - const source = defaultValues.sourceOptions.find(u => u.id === conversionState.sourceId); - return source?.typeOfUnit === UnitType.meter; + const handleBidirectionalChange = (e: React.ChangeEvent) => { + const value = JSON.parse(e.target.value); + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + bidirectional: value + } + })); }; - // Determine whether the selected source or destination is a suffix unit - const isSuffixUsed = () => { - const source = defaultValues.sourceOptions.find(u => u.id === conversionState.sourceId); - const dest = defaultValues.sourceOptions.find(u => u.id === conversionState.destinationId); - return source?.typeOfUnit === UnitType.suffix || dest?.typeOfUnit === UnitType.suffix; + const handlePatternChange = (e: React.ChangeEvent) => { + const selectedValue = Number(e.target.value); + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + pattern: selectedValue, + slope: defaultValues.slope, + intercept: defaultValues.intercept + } + })); }; - /* Warning Modal */ const handleWarningConfirm = () => { //Close the warning modal setShowWarningModal(false); + submitConversion(); + }; - //Proceed with the creation of the conversion + const handleClose = () => { setShowModal(false); - addConversionMutation({...omit(conversionState, 'sourceOptions'), - bidirectional: (isMeterSource() || isSuffixUsed()) ? false : conversionState.bidirectional}); resetState(); }; + const handleShow = () => setShowModal(true); + const handleWarningCancel = () => { - //Close the warning modal setShowWarningModal(false); }; - // Reset the state to default values - const resetState = () => { - setConversionState(defaultValues); - }; - /* End Warning Modal */ - - // Submit + // Checks if slope and intercept are both zero with no pattern and shows a warning modal. + // Otherwise, if the conversion is valid, submits the conversion and resets state. + // If invalid, shows an error notification. const handleSubmit = () => { - // Show warning modal if slope and intercept are both 0 - if (conversionState.slope === 0 && conversionState.intercept === 0) { + // Show warning modal if slope and intercept are both 0 and no pattern + if ( + conversionState.initialConversion.slope === 0 && + conversionState.initialConversion.intercept === 0 && + noPattern + ) { setWarningMessage(translate('conversion.slope.intercept.zero')); setShowWarningModal(true); } else if (validConversion) { - // Close modal first to avoid repeat clicks - setShowModal(false); - // Add the new conversion and update the store - // Omit the source options , do not need to send in request so remove here. - // If source is a meter, make bidirectional false - // If source or destination is a suffix unit, make bidirectional false - addConversionMutation({...omit(conversionState, 'sourceOptions'), - bidirectional: (isMeterSource() || isSuffixUsed()) ? false : conversionState.bidirectional}); - resetState(); + submitConversion(); } else { showErrorNotification(reason); } }; + /* End Handlers */ const tooltipStyle = { ...tooltipBaseStyle, @@ -176,13 +272,13 @@ export default function CreateConversionModalComponent() { id='sourceId' name='sourceId' type='select' - value={conversionState.sourceId} - onChange={e => handleNumberChange(e)} - invalid={conversionState.sourceId === -999}> + value={conversionState.overallConversion.sourceId} + onChange={e => handleSourceIdChange(e)} + invalid={conversionState.overallConversion.sourceId === -999}> {} @@ -203,13 +299,13 @@ export default function CreateConversionModalComponent() { id='destinationId' name='destinationId' type='select' - value={conversionState.destinationId} - onChange={e => handleNumberChange(e)} - invalid={conversionState.destinationId === -999}> + value={conversionState.overallConversion.destinationId} + onChange={e => handleDestinationIdChange(e)} + invalid={conversionState.overallConversion.destinationId === -999}> {} @@ -230,65 +326,97 @@ export default function CreateConversionModalComponent() { id='bidirectional' name='bidirectional' type='select' - onChange={e => handleBooleanChange(e)} - value={String(conversionState.bidirectional)} - invalid={(isMeterSource() || isSuffixUsed()) && conversionState.bidirectional === true}> + onChange={e => handleBidirectionalChange(e)} + value={String(conversionState.overallConversion.bidirectional)} + invalid={(isMeterSource() || isSuffixUsed()) && conversionState.overallConversion.bidirectional === true}> {Object.keys(TrueFalseType).map(key => { return (); })} - {isMeterSource() && conversionState.bidirectional === true && ( + {isMeterSource() && conversionState.overallConversion.bidirectional === true && ( )} - {isSuffixUsed() && conversionState.bidirectional === true && ( + {isSuffixUsed() && conversionState.overallConversion.bidirectional === true && ( )} + {/* Note input for overall conversion*/} + + + handleOverallNoteChange(e)} + value={conversionState.overallConversion.note} /> + + {/*Initial conversion*/} +
+ +
{/* Slope input*/} - + handleNumberChange(e)} /> + value={conversionState.initialConversion.slope} + onChange={e => handleNumberChange(e)} + disabled={!noPattern} /> {/* Intercept input*/} - + handleNumberChange(e)} /> + value={conversionState.initialConversion.intercept} + onChange={e => handleNumberChange(e)} + disabled={!noPattern} /> - {/* Note input*/} + {/* Pattern dropdown for weekly pattern or no pattern */} - + + + + {weeks.map(week => ( + + ))} + + + {/* Note input for initial conversion*/} + + handleStringChange(e)} - value={conversionState.note} /> + onChange={e => handleInitialNoteChange(e)} + value={conversionState.initialConversion.segmentNote} /> { - // Todo looks kind of bad make a better visible notification + // TODO looks kind of bad make a better visible notification !validConversion &&

{reason}

} diff --git a/src/client/app/components/conversion/DeleteConversionSegmentModalComponent.tsx b/src/client/app/components/conversion/DeleteConversionSegmentModalComponent.tsx new file mode 100644 index 0000000000..fb75f83dc3 --- /dev/null +++ b/src/client/app/components/conversion/DeleteConversionSegmentModalComponent.tsx @@ -0,0 +1,75 @@ +/* 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 { ConversionSegmentData } from '../../types/redux/conversionSegments'; +import { useTranslate } from '../../redux/componentHooks'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import { conversionSegmentsApi } from '../../redux/api/conversionSegmentsApi'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface DeleteConversionSegmentModalComponentProps { + show: boolean; + direction: 'earlier' | 'later'; + segment: ConversionSegmentData; + handleClose: () => void; +} + +/** + * Renders a modal that confirms deletion of a conversion segment + * in the 'earlier' or 'later' direction. Mutates the segment and + * shows a success or error notification. + * @param props Props for the component + * @returns Modal component for deletion confirmation + */ +export default function DeleteConversionSegmentModalComponent(props: DeleteConversionSegmentModalComponentProps): React.ReactElement { + const translate = useTranslate(); + + const [deleteEarlier] = conversionSegmentsApi.useDeleteConversionSegmentEarlierMutation(); + const [deleteLater] = conversionSegmentsApi.useDeleteConversionSegmentLaterMutation(); + + const handleHideDeleteModal = () => { + props.handleClose(); + }; + + const handleDeleteSegment = async () => { + const payload = { + sourceId: props.segment.sourceId, + destinationId: props.segment.destinationId, + startTime: props.segment.startTime, + endTime: props.segment.endTime + }; + + try { + if (props.direction === 'earlier') { + await deleteEarlier(payload).unwrap(); + } else { + await deleteLater(payload).unwrap(); + } + showSuccessNotification(translate('conversion.segment.delete.success')); + handleHideDeleteModal(); + } catch (error) { + showErrorNotification(translate('conversion.segment.delete.error')); + } + }; + + const deleteConfirmationMessage = props.direction === 'earlier' + ? translate('conversion.delete.segment.earlier') + : translate('conversion.delete.segment.later'); + + return ( + + ); +} diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index 8959062efb..b24b8a0452 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -1,24 +1,37 @@ /* 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 moment from 'moment-timezone'; import * as React from 'react'; // Realize that * is already imported from react import { useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Button, Col, Container, FormGroup, FormFeedback, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; -import TooltipHelpComponent from '../TooltipHelpComponent'; +import { + Button, Col, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, + ModalFooter, ModalHeader, Pagination, PaginationItem, PaginationLink, Row, Table +} from 'reactstrap'; import { conversionsApi, selectConversionsDetails } from '../../redux/api/conversionsApi'; +import { conversionSegmentsApi } from '../../redux/api/conversionSegmentsApi'; import { selectMeterDataById } from '../../redux/api/metersApi'; import { selectUnitDataById } from '../../redux/api/unitsApi'; +import { weeksApi } from '../../redux/api/weeksApi'; +import { useTranslate } from '../../redux/componentHooks'; import { useAppSelector } from '../../redux/reduxHooks'; import '../../styles/modal.css'; -import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { tooltipBaseStyle, modalSectionDividerStyle } from '../../styles/modalStyle'; import { TrueFalseType } from '../../types/items'; import { ConversionData } from '../../types/redux/conversions'; +import { ConversionSegmentData, UpdateConversionSegmentPayload } from '../../types/redux/conversionSegments'; import { UnitData, UnitType } from '../../types/redux/units'; -import { useTranslate } from '../../redux/componentHooks'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import EditConversionSegmentModalComponent from './EditConversionSegmentModalComponent'; +import SplitConversionSegmentModalComponent from './SplitConversionSegmentModalComponent'; +import DeleteConversionSegmentModalComponent from './DeleteConversionSegmentModalComponent'; +import { ROWS_PER_TABLE } from '../../utils/pagination'; +import { showSuccessNotification, showErrorNotification } from '../../utils/notifications'; interface EditConversionModalComponentProps { show: boolean; @@ -39,6 +52,11 @@ export default function EditConversionModalComponent(props: EditConversionModalC const translate = useTranslate(); const [editConversion] = conversionsApi.useEditConversionMutation(); const [deleteConversion] = conversionsApi.useDeleteConversionMutation(); + const getSegments = conversionSegmentsApi.useGetConversionSegmentByConversionQuery({ + sourceId: props.conversion.sourceId, + destinationId: props.conversion.destinationId + }); + const getWeeks = weeksApi.useGetWeeksQuery(); const unitDataById = useAppSelector(selectUnitDataById); const meterDataById = useAppSelector(selectMeterDataById); const conversionDetails = useAppSelector(selectConversionsDetails); @@ -49,17 +67,36 @@ export default function EditConversionModalComponent(props: EditConversionModalC /* State */ // Handlers for each type of input change const [state, setState] = useState(values); + // Tracks the active segment being edited, with original start/end times used to match and update the correct entry in the backend + const [editingSegment, setEditingSegment] = useState(null); + const [showSegmentNoteModal, setShowSegmentNoteModal] = React.useState(false); + const [showEditSegmentModal, setShowEditSegmentModal] = React.useState(false); + const [showSplitSegmentModal, setShowSplitSegmentModal] = React.useState(false); + const [showDeleteSegmentModal, setShowDeleteSegmentModal] = useState(false); + const [actionDirection, setActionDirection] = useState<'earlier' | 'later' | null>(null); + const [selectedSegment, setSelectedSegment] = useState(null); + const [currentPage, setCurrentPage] = React.useState(1); + const [showAllSegments, setShowAllSegments] = React.useState(false); + + // Extract data and utilities from query hooks for easier usage + const segments = getSegments.data ?? []; + const weekPatterns = getWeeks.data ?? []; + + const PER_TABLE = ROWS_PER_TABLE; + const totalPages = Math.ceil(segments.length / PER_TABLE); 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) }); + setState({ ...state, [e.target.name]: JSON.parse(e.target.value) }); }; - const handleNumberChange = (e: React.ChangeEvent) => { - setState({ ...state, [e.target.name]: Number(e.target.value) }); + // Opens a modal showing the full segment note + const handleNoteModal = (segment: ConversionSegmentData) => { + setSelectedSegment(segment); + setShowSegmentNoteModal(true); }; /* End State */ @@ -246,22 +283,15 @@ export default function EditConversionModalComponent(props: EditConversionModalC props.handleClose(); resetState(); }; - /* Warning Modal State */ - const [showWarningModal, setShowWarningModal] = useState(false); - const [warningMessage, setWarningMessage] = useState(''); - const handleWarningConfirm = () => { - // Close the warning modal - setShowWarningModal(false); - - // Proceed with saving changes - // Close the modal first to avoid repeat clicks - props.handleClose(); - - // Need to redo Cik if slope, intercept, or bidirectional changes. - const shouldRedoCik = props.conversion.slope !== state.slope - || props.conversion.intercept !== state.intercept - || props.conversion.bidirectional !== state.bidirectional; + // Save changes + // Currently using the old functionality which is to compare inherited prop values to state values + // If there is a difference between props and state, then a change was made + // Side note, we could probably just set a boolean when any input i + // Edit Conversion Validation: is not needed as no breaking edits can be made + const handleSaveChanges = () => { + // Need to redo Cik if bidirectional changes. + const shouldRedoCik = props.conversion.bidirectional !== state.bidirectional; // Check for changes by comparing state to props const conversionHasChanges = shouldRedoCik || props.conversion.note != state.note; // Only do work if there are changes @@ -270,42 +300,17 @@ export default function EditConversionModalComponent(props: EditConversionModalC editConversion({ conversionData: { ...state, - bidirectional: (isMeterSource() || isSuffixUsed()) ? false : state.bidirectional }, shouldRedoCik }); - } - }; - const handleWarningCancel = () => { - // Close the warning modal - setShowWarningModal(false); - }; - - // Save changes - // Currently using the old functionality which is to compare inherited prop values to state values - // If there is a difference between props and state, then a change was made - // Side note, we could probably just set a boolean when any input i - // Edit Conversion Validation: is not needed as no breaking edits can be made - const handleSaveChanges = () => { - // Check if slope and intercept are both 0 - if (state.slope === 0 && state.intercept === 0) { - setWarningMessage(translate('conversion.slope.intercept.zero')); - setShowWarningModal(true); - } else { - // Close the modal first to avoid repeat clicks - props.handleClose(); - - // Need to redo Cik if slope, intercept, or bidirectional changes. - const shouldRedoCik = props.conversion.slope !== state.slope - || props.conversion.intercept !== state.intercept - || props.conversion.bidirectional !== state.bidirectional; - // Check for changes by comparing state to props - const conversionHasChanges = shouldRedoCik || props.conversion.note != state.note; - // Only do work if there are changes - if (conversionHasChanges) { - // Save our changes - editConversion({ - conversionData: { - ...state, - bidirectional: (isMeterSource() || isSuffixUsed()) ? false : state.bidirectional }, shouldRedoCik }); - } + bidirectional: (isMeterSource() || isSuffixUsed()) ? false : state.bidirectional + }, shouldRedoCik + }) + .unwrap() + .then(() => { + showSuccessNotification(translate('conversion.overall.save.success')); + props.handleClose(); + }) + .catch(() => { + showErrorNotification(translate('conversion.overall.save.error')); + }); } }; @@ -316,14 +321,6 @@ export default function EditConversionModalComponent(props: EditConversionModalC return ( <> - {/* Warning Modal */} - - + setShowSegmentNoteModal(false)} centered> + setShowSegmentNoteModal(false)}> + {selectedSegment?.startTime} to {selectedSegment?.endTime} + + + {selectedSegment?.note} + + + {/* Segment Edit Modal */} + {showEditSegmentModal && + { + setShowEditSegmentModal(false); + setEditingSegment(null); + }} + /> + } + {/* Segment Split Modal */} + {selectedSegment && actionDirection && ( + { + setShowSplitSegmentModal(false); + setSelectedSegment(null); + setActionDirection(null); + }} + /> + )} + {/* Segment Delete Modal */} + {selectedSegment && actionDirection && ( + { + setShowDeleteSegmentModal(false); + setSelectedSegment(null); + setActionDirection(null); + }} + /> + )} + - +
+

+ +

{/* when any of the conversion are changed call one of the functions. */} @@ -396,41 +444,15 @@ export default function EditConversionModalComponent(props: EditConversionModalC {isMeterSource() && state.bidirectional === true && ( - + )} {isSuffixUsed() && state.bidirectional === true && ( - + )} - - - {/* Slope input */} - - - handleNumberChange(e)} /> - - - - {/* Intercept input */} - - - handleNumberChange(e)} /> - - - {/* Note input */} @@ -442,19 +464,161 @@ export default function EditConversionModalComponent(props: EditConversionModalC placeholder='Note' onChange={e => handleStringChange(e)} /> +
+
+

+ +

+ + + + + + + + + + + + + + + + + {(showAllSegments ? segments : segments.slice((currentPage - 1) * PER_TABLE, currentPage * PER_TABLE)) + .map(segment => ( + + + + + + + + + + + + + ))} + +
{segment.startTime} to {segment.endTime}{(segment.weekPatternsId ?? -99) === -99 ? segment.slope : ''}{(segment.weekPatternsId ?? -99) === -99 ? segment.intercept : ''}{weekPatterns.find(wp => wp.id === segment.weekPatternsId)?.name ?? translate('conversion.pattern.no')} handleNoteModal(segment)} + aria-label={segment.note} + > + {(segment.note ?? '').length > 30 ? `${segment.note.slice(0, 30)} ...` : segment.note || ''} + + + + {segments.length !== 1 && segment.startTime !== '-infinity' && ( + + )} + + {segments.length !== 1 && segment.endTime !== 'infinity' && ( + + )} +
+ {!showAllSegments && segments.length !== 0 && ( + + <> + + setCurrentPage(1)} /> + + + setCurrentPage(currentPage - 1)} /> + + + {Array.from({ length: totalPages }, (_, index) => ( + + setCurrentPage(index + 1)}> + {index + 1} + + + ))} + + setCurrentPage(currentPage + 1)} /> + + + setCurrentPage(totalPages)} /> + + + + )} +
+ {/* Show all logs or in pages button */} + {segments.length > 0 && + } +
{/* Hides the modal */} {/* On click calls the function handleSaveChanges in this component */} -
diff --git a/src/client/app/components/conversion/EditConversionSegmentModalComponent.tsx b/src/client/app/components/conversion/EditConversionSegmentModalComponent.tsx new file mode 100644 index 0000000000..42c778eef7 --- /dev/null +++ b/src/client/app/components/conversion/EditConversionSegmentModalComponent.tsx @@ -0,0 +1,307 @@ +/* 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 { ConversionSegmentData, UpdateConversionSegmentPayload } from '../../types/redux/conversionSegments'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import { conversionSegmentsApi } from '../../redux/api/conversionSegmentsApi'; +import { Week } from '../../types/redux/weeks'; +import * as moment from 'moment-timezone'; +import { useTranslate } from '../../redux/componentHooks'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface EditConversionSegmentModalComponentProps { + show: boolean; + segment: UpdateConversionSegmentPayload; + weekPatterns: Week[]; + segments: ConversionSegmentData[]; + handleClose: () => void; +} + +/** + * Renders a modal for editing a conversion segment. + * Users can modify slope, intercept, start/end times, weekly pattern, and notes. + * @param props The properties for the component + * @returns A modal element for editing a conversion segment + */ +export default function EditConversionSegmentModalComponent(props: EditConversionSegmentModalComponentProps): React.ReactElement { + const translate = useTranslate(); + const [segment, setSegment] = React.useState(props.segment); + const [editSegment] = conversionSegmentsApi.useEditConversionSegmentMutation(); + const [fieldErrors, setFieldErrors] = React.useState<{ startTimeError?: string, endTimeError?: string }>({}); + const [showWarningModal, setShowWarningModal] = React.useState(false); + + const handleNumberChange = (e: React.ChangeEvent) => { + setSegment(prev => ({ + ...prev, + [e.target.name]: Number(e.target.value), + weekPatternsId: -99 + })); + }; + + const handleDatetimeChange = (e: React.ChangeEvent) => { + setSegment(prev => ({ + ...prev, + [e.target.name]: e.target.value + })); + setFieldErrors(prev => ({ + ...prev, + [`${name}Error`]: undefined + })); + }; + + const handlePatternChange = (e: React.ChangeEvent) => { + const selectedPattern = Number(e.target.value); + setSegment(prev => { + // If "No Pattern" is selected, keep slope/intercept; otherwise reset them + if (selectedPattern === -99) { + return { + ...prev, + weekPatternsId: -99, + slope: prev.slope, + intercept: prev.intercept + }; + } else { + return { + ...prev, + weekPatternsId: selectedPattern, + slope: 0, + intercept: 0 + }; + } + }); + }; + + const handleNoteChange = (e: React.ChangeEvent) => { + setSegment(prev => ({ + ...prev, + note: e.target.value + })); + }; + + const doMutation = async () => { + try { + await editSegment({ + segment, + originalStartTime: segment.originalStartTime, + originalEndTime: segment.originalEndTime + }).unwrap(); + showSuccessNotification(translate('conversion.segment.save.success')); + props.handleClose(); + } catch (error) { + showErrorNotification(translate('conversion.segment.save.error')); + } + }; + + const handleEditSegment = () => { + const isStartTimeValid = moment(segment.startTime, 'YYYY-MM-DD HH:mm:ss', true).isValid() || segment.startTime === '-infinity'; + const isEndTimeValid = moment(segment.endTime, 'YYYY-MM-DD HH:mm:ss', true).isValid() || segment.endTime === 'infinity'; + + if (!isStartTimeValid) { + setFieldErrors({ startTimeError: translate('conversion.error.datetime.invalid') }); + return; + } + if (!isEndTimeValid) { + setFieldErrors({ endTimeError: translate('conversion.error.datetime.invalid') }); + return; + } + + const wasAlreadyNeutral = + props.segment.slope === 0 && + props.segment.intercept === 0 && + props.segment.weekPatternsId === -99; + + const isNowNeutral = + segment.slope === 0 && + segment.intercept === 0 && + segment.weekPatternsId === -99; + + // Show warning if neutral slope/intercept and no pattern + if (isNowNeutral && !wasAlreadyNeutral) { + setShowWarningModal(true); + return; + } + + // Find the index of the segment being edited + const index = props.segments.findIndex( + seg => seg.startTime === segment.originalStartTime && seg.endTime === segment.originalEndTime + ); + + // Get previous and next segments + const previous = props.segments[index - 1]; + const next = props.segments[index + 1]; + + // When editing a segment's time range, automatically adjust the neighboring segments + // to keep the timeline continuous (no gaps or overlaps). + if (segment.startTime !== '-infinity' && previous) { + const newStart = moment.utc(segment.startTime, 'YYYY-MM-DD HH:mm:ss'); + const prevEnd = moment.utc(previous.endTime, 'YYYY-MM-DD HH:mm:ss'); + const currentEnd = moment.utc(segment.endTime, 'YYYY-MM-DD HH:mm:ss'); + + // Validation: new start must be after prevEnd AND before this segment's end + if (!newStart.isBefore(currentEnd)) { + setFieldErrors({ startTimeError: translate('conversion.segment.warning.invalidStartRange') }); + return; + } else if (!newStart.isSame(prevEnd)) { + editSegment({ + segment: { + ...previous, + endTime: newStart.format('YYYY-MM-DD HH:mm:ss') + }, + originalStartTime: previous.startTime, + originalEndTime: previous.endTime + }).unwrap() + .then(() => { + showSuccessNotification(translate('conversion.segment.warning.previousAdjusted')); + }) + .catch(() => { + showErrorNotification(translate('conversion.segment.warning.previousAdjustFailed')); + }); + } + } + + if (segment.endTime !== 'infinity' && next) { + const newEnd = moment.utc(segment.endTime, 'YYYY-MM-DD HH:mm:ss'); + const nextStart = moment.utc(next.startTime, 'YYYY-MM-DD HH:mm:ss'); + const currentStart = moment.utc(segment.startTime, 'YYYY-MM-DD HH:mm:ss'); + + + // Validation: new end must be before nextStart AND after this segment's start + if (!newEnd.isAfter(currentStart)) { + setFieldErrors({ endTimeError: translate('conversion.segment.warning.invalidEndRange') }); + return; + } else if (!newEnd.isSame(nextStart)) { + editSegment({ + segment: { + ...next, + startTime: newEnd.format('YYYY-MM-DD HH:mm:ss') + }, + originalStartTime: next.startTime, + originalEndTime: next.endTime + }).unwrap() + .then(() => { + showSuccessNotification(translate('conversion.segment.warning.nextAdjusted')); + }) + .catch(() => { + showErrorNotification(translate('conversion.segment.warning.nextAdjustFailed')); + }); + } + } + + doMutation(); + }; + + const isSegmentUnchanged = React.useMemo(() => { + return props.segment.startTime === segment.startTime && + props.segment.endTime === segment.endTime && + props.segment.slope === segment.slope && + props.segment.intercept === segment.intercept && + props.segment.weekPatternsId === segment.weekPatternsId && + (props.segment.note ?? '') === (segment.note ?? ''); + }, [props.segment, segment]); + + return ( + + + + + + + + {/* !!fieldErrors converts string/undefined into a strict boolean */} + handleDatetimeChange(e)} + invalid={!!fieldErrors.startTimeError} + disabled={segment.startTime === '-infinity'} + /> + {fieldErrors.startTimeError} + + + + {/* !!fieldErrors converts string/undefined into a strict boolean */} + handleDatetimeChange(e)} + invalid={!!fieldErrors.endTimeError} + disabled={segment.endTime === 'infinity'} + /> + {fieldErrors.endTimeError} + + + + handleNumberChange(e)} + disabled={segment.weekPatternsId !== -99} + /> + + + + handleNumberChange(e)} + disabled={segment.weekPatternsId !== -99} + /> + + + + handlePatternChange(e)} + > + + {props.weekPatterns.map(pattern => ( + + ))} + + + + + handleNoteChange(e)} + /> + + + + + + + setShowWarningModal(false)} + actionFunction={() => { + setShowWarningModal(false); + doMutation(); + }} + actionConfirmText={translate('confirm.action')} + actionRejectText={translate('cancel')} + /> + + ); +} diff --git a/src/client/app/components/conversion/SplitConversionSegmentModalComponent.tsx b/src/client/app/components/conversion/SplitConversionSegmentModalComponent.tsx new file mode 100644 index 0000000000..33afb74b45 --- /dev/null +++ b/src/client/app/components/conversion/SplitConversionSegmentModalComponent.tsx @@ -0,0 +1,233 @@ +/* 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 { ConversionSegmentData, SplitConversionSegmentPayload } from '../../types/redux/conversionSegments'; +import { Week } from '../../types/redux/weeks'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import { conversionSegmentsApi } from '../../redux/api/conversionSegmentsApi'; +import { selectDefaultSplitConversionSegmentValues } from '../../redux/selectors/adminSelectors'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import { useTranslate } from '../../redux/componentHooks'; +import * as moment from 'moment-timezone'; + +interface SplitConversionSegmentModalComponentProps { + show: boolean; + direction: 'earlier' | 'later'; + segment: ConversionSegmentData; + weekPatterns: Week[]; + handleClose: () => void; +} + +/** + * Renders a modal that allows the user to split a conversion segment in either direction. + * The new segment starts with neutral values (slope=0, intercept=0, no pattern) + * so the admin must provide or confirm values before saving. + * @param props The properties for the component + * @returns A modal component for splitting a conversion segment + */ +export default function SplitConversionSegmentModalComponent(props: SplitConversionSegmentModalComponentProps): React.ReactElement { + const translate = useTranslate(); + const defaultSegmentValues = selectDefaultSplitConversionSegmentValues(props.segment); + const [newSegment, setNewSegment] = React.useState(defaultSegmentValues); + const [showWarningModal, setShowWarningModal] = React.useState(false); + const [fieldError, setFieldError] = React.useState(''); + + const [splitEarlier] = conversionSegmentsApi.useSplitConversionSegmentEarlierMutation(); + const [splitLater] = conversionSegmentsApi.useSplitConversionSegmentLaterMutation(); + + 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 + })); + }; + + const handlePatternChange = (e: React.ChangeEvent) => { + const selectedPattern = Number(e.target.value); + setNewSegment(prev => { + if (selectedPattern === -99) { + return { + ...prev, + newWeekPatternsId: -99, + newSlope: 0, + newIntercept: 0 + }; + } else { + return { + ...prev, + newWeekPatternsId: selectedPattern + }; + } + }); + }; + + const handleSplitTimeChange = (e: React.ChangeEvent) => { + setNewSegment(prev => ({ + ...prev, + splitTime: e.target.value + })); + if (fieldError) { + setFieldError(''); + } + }; + + + const doMutation = async () => { + const payload: SplitConversionSegmentPayload = { + ...newSegment, + sourceId: props.segment.sourceId, + destinationId: props.segment.destinationId, + startTime: props.segment.startTime, + endTime: props.segment.endTime, + splitTime: newSegment.splitTime, + newWeekPatternsId: newSegment.newWeekPatternsId, + newNote: newSegment.newNote || '' + }; + + try { + if (props.direction === 'earlier') { + await splitEarlier(payload).unwrap(); + } else { + await splitLater(payload).unwrap(); + } + showSuccessNotification(translate('conversion.segment.split.success')); + props.handleClose(); + } catch (error) { + showErrorNotification(translate('conversion.segment.split.error')); + } + }; + + const handleSplitSegment = async () => { + const splitDatetime = moment(newSegment.splitTime, ['YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD'], true); + + if (!splitDatetime.isValid()) { + setFieldError(translate('conversion.error.split.time.invalid')); + return; + } + + // If segment.startTime is not -infinity, ensure split > startTime + if (props.segment.startTime !== '-infinity' && splitDatetime.isSameOrBefore(moment(props.segment.startTime))) { + setFieldError(translate('conversion.error.split.beforeStart')); + return; + } + + // If segment.endTime is not infinity, ensure split < endTime + if (props.segment.endTime !== 'infinity' && splitDatetime.isSameOrAfter(moment(props.segment.endTime))) { + setFieldError(translate('conversion.error.split.afterEnd')); + return; + } + + if (newSegment.newSlope === 0 && newSegment.newIntercept === 0 && newSegment.newWeekPatternsId === -99) { + setShowWarningModal(true); + return; + } + + doMutation(); + }; + + return ( + <> + + + + + + + + {/* !!fieldError converts string/undefined into a strict boolean */} + handleSplitTimeChange(e)} + invalid={!!fieldError} + /> + {fieldError} + + + + handleNumberChange(e)} + disabled={newSegment.newWeekPatternsId !== -99} + /> + + + + handleNumberChange(e)} + disabled={newSegment.newWeekPatternsId !== -99} + /> + + + + handlePatternChange(e)} + > + + {props.weekPatterns.map(pattern => ( + + ))} + + + + + handleStringChange(e)} + /> + + + + + + + + + setShowWarningModal(false)} + actionFunction={() => { + setShowWarningModal(false); + doMutation(); + }} + actionConfirmText={translate('confirm.action')} + actionRejectText={translate('cancel')} + /> + + ); +} diff --git a/src/client/app/components/days/CreateDayModalComponent.tsx b/src/client/app/components/days/CreateDayModalComponent.tsx new file mode 100644 index 0000000000..a0fca34728 --- /dev/null +++ b/src/client/app/components/days/CreateDayModalComponent.tsx @@ -0,0 +1,295 @@ +/* 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, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; +import { daysApi } from '../../redux/api/daysApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectDefaultCreateDayValues, selectIsValidCreateDay } from '../../redux/selectors/adminSelectors'; +import '../../styles/modal.css'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; + +/** + * Defines the create day modal form + * @returns Day create element + */ +export default function CreateDayModalComponent() { + /* Hooks & Selectors */ + const translate = useTranslate(); + const [addDayMutation] = daysApi.useAddDayMutation(); + const defaultValues = useAppSelector(selectDefaultCreateDayValues); + /* End Hooks & Selectors */ + + /* Utility Functions */ + // Utility to get the initial pattern state + const getInitialPatternState = () => ({ + Day: { + name: defaultValues.name, + note: defaultValues.DayNote + }, + initialSegment: { + slope: defaultValues.slope, + intercept: defaultValues.intercept, + startHour: defaultValues.startHour, + endHour: defaultValues.endHour, + segmentNote: defaultValues.initialSegmentNote + } + }); + + // Reset the state to default values + const resetState = () => { + setPatternState(getInitialPatternState()); + }; + + const submitDay = () => { + // Close modal first to avoid repeat clicks + setShowModal(false); + addDayMutation({ + name: patternState.Day.name, + slope: patternState.initialSegment.slope, + intercept: patternState.initialSegment.intercept, + note: patternState.Day.note, + segmentNote: patternState.initialSegment.segmentNote + }).unwrap() + .then(() => { + showSuccessNotification(translate('day.create.success')); + }) + .catch(error => { + showErrorNotification(translate('day.create.failure') + error); + }); + resetState(); + }; + /* End Utility Functions */ + + /* State */ + const [showModal, setShowModal] = useState(false); + const [showWarningModal, setShowWarningModal] = useState(false); + const [warningMessage, setWarningMessage] = useState(''); + const [patternState, setPatternState] = useState(getInitialPatternState()); + /* End State */ + + /* Derived State*/ + const [isValidDay, reason] = useAppSelector(state => + selectIsValidCreateDay(state, patternState) + ); + /* End Derived State */ + + /* Handlers */ + const handleInitialSegmentNoteChange = (e: React.ChangeEvent) => { + setPatternState(prev => ({ + ...prev, + initialSegment: { + ...prev.initialSegment, + segmentNote: e.target.value + } + })); + }; + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setPatternState(prev => ({ + ...prev, + Day: { + ...prev.Day, + [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 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 slope and intercept are both 0 + if (patternState.initialSegment.slope === 0 && patternState.initialSegment.intercept === 0) { + setWarningMessage(translate('day.slope.intercept.zero')); + setShowWarningModal(true); + } else { + submitDay(); + } + }; + /* End Handlers */ + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipCreateConversionView: 'help.admin.daycreate' + }; + + return ( + <> + + {/* Show modal button */} + + + + + + +
+ +
+
+ {/* when any of the conversion are changed call one of the functions. */} + + + {/* Name for daily pattern*/} + + + handleTextChange(e)} + value={patternState.Day.name} + invalid={!patternState.Day.name} // TODO: OED needs to decide how to trim names universally + required + /> + + + + + {/* Note input for overall conversion*/} + + + handleTextChange(e)} + value={patternState.Day.note} /> + + {/*Initial day segment*/} +
+ +
+ + + {/* Slope input*/} + + + handleNumberChange(e)} /> + + + + {/* Intercept input*/} + + + handleNumberChange(e)} /> + + + + + + {/* Start hour input*/} + + + + + + + {/* End hour input*/} + + + + + + + {/* Note input for initial pattern*/} + + + handleInitialSegmentNoteChange(e)} + value={patternState.initialSegment.segmentNote} /> + +
+
+ + { + // TODO: looks kind of bad make a better visible notification + !isValidDay &&

{reason}

+ } + {/* Hides the modal */} + + {/* On click calls the function handleSaveChanges in this component */} + +
+
+ + ); +} diff --git a/src/client/app/components/days/DayViewComponent.tsx b/src/client/app/components/days/DayViewComponent.tsx new file mode 100644 index 0000000000..6e8522bb81 --- /dev/null +++ b/src/client/app/components/days/DayViewComponent.tsx @@ -0,0 +1,55 @@ +/* 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 } from 'reactstrap'; +import { Day } from 'types/redux/days'; +import '../../styles/card-page.css'; +import EditDailyPatternModalComponent from './EditDayModalComponent'; + +interface DayViewComponentProps { + day: Day; +} + +/** + * Defines the daily pattern info card + * @param props defined above + * @returns Single day element + */ +export default function DayViewComponent(props: DayViewComponentProps) { + // Edit Modal Show + const [showEditModal, setShowEditModal] = React.useState(false); + + const handleShow = () => { + setShowEditModal(true); + }; + + const handleClose = () => { + setShowEditModal(false); + }; + + return ( +
+
+ {props.day.name} +
+
+ {/* Only show first 30 characters so card does not get too big. Should limit to one line */} + {props.day.note.slice(0, 29)} +
+
+ + {/* Creates a child DayModalEditComponent */} + +
+
+ ); +} diff --git a/src/client/app/components/days/DaysDetailComponent.tsx b/src/client/app/components/days/DaysDetailComponent.tsx new file mode 100644 index 0000000000..da49d11e5f --- /dev/null +++ b/src/client/app/components/days/DaysDetailComponent.tsx @@ -0,0 +1,61 @@ +/* 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 { daysApi, stableEmptyDays } from '../../redux/api/daysApi'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import CreateDayModalComponent from './CreateDayModalComponent'; +import DayViewComponent from './DayViewComponent'; + +/** + * Defines the daily pattern info page + * @returns Single day element + */ +export default function DaysDetailComponent() { + const locale = useAppSelector(selectSelectedLanguage); + + // Day state + const { data: dayState = stableEmptyDays } = daysApi.useGetDaysQuery(); + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipDayView: 'help.admin.dayview' + }; + + const sortedDays = React.useMemo(() => { + return Object.values(dayState) + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, { sensitivity: 'accent' })); + }, [dayState, locale]); + + return ( +
+
+ +
+

+ +
+ +
+

+
+ {/* Placeholder for create day modal */} + +
+
+ {sortedDays + .map(day => ( + + ))} +
+
+
+
+ ); +} diff --git a/src/client/app/components/days/DeleteDaySegmentComponent.tsx b/src/client/app/components/days/DeleteDaySegmentComponent.tsx new file mode 100644 index 0000000000..4bead0f51a --- /dev/null +++ b/src/client/app/components/days/DeleteDaySegmentComponent.tsx @@ -0,0 +1,87 @@ +/* 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 { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { DaySegment } from '../../types/redux/days'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface DeleteDaySegmentComponentProps { + /** + * The direction to delete the segment + */ + direction: 'earlier' | 'later'; + + /** + * The day segment to delete + */ + daySegment: DaySegment; +} + +/** + * Defines a button that opens a modal to delete a day 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 DeleteDaySegmentComponent(props: DeleteDaySegmentComponentProps): React.ReactElement { + const translate = useTranslate(); + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + + const handleShowDeleteModal = () => setShowDeleteModal(true); + const handleHideDeleteModal = () => setShowDeleteModal(false); + + const [deleteDaySegmentEarlierMutation] = daySegmentsApi.useDeleteDaySegmentEarlierMutation(); + const [deleteDaySegmentLaterMutation] = daySegmentsApi.useDeleteDaySegmentLaterMutation(); + + const handleDeleteDaySegment = async () => { + try { + if (props.direction === 'earlier') { + await deleteDaySegmentEarlierMutation(props.daySegment).unwrap(); + } else if (props.direction === 'later') { + await deleteDaySegmentLaterMutation(props.daySegment).unwrap(); + } else { + throw new Error( + translate('day.segments.delete.invalid.direction').replace('{direction}', props.direction) + ); + } + handleHideDeleteModal(); + showSuccessNotification(translate('day.segments.delete.success')); + } catch (error) { + showErrorNotification(error); + } + }; + + const deleteButtonText = props.direction === 'earlier' + ? translate('delete.earlier') : translate('delete.later'); + + const deleteConfirmationMessage = props.direction === 'earlier' + ? translate('day.segments.delete.confirm.earlier') + : translate('day.segments.delete.confirm.later'); + + return ( + <> + + + {/* Delete day segment confirmation modal */} + + + ); +} diff --git a/src/client/app/components/days/EditDayModalComponent.tsx b/src/client/app/components/days/EditDayModalComponent.tsx new file mode 100644 index 0000000000..3c249f2c94 --- /dev/null +++ b/src/client/app/components/days/EditDayModalComponent.tsx @@ -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/. */ + +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + Button, + Container, FormFeedback, + FormGroup, Input, Label, Modal, ModalBody, ModalFooter, + ModalHeader, Pagination, PaginationItem, PaginationLink, + Table +} from 'reactstrap'; +import { Day, DaySegment } from 'types/redux/days'; +import { daysApi } from '../../redux/api/daysApi'; +import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { modalSectionDividerStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import { LocaleDataKey } from '../../translations/data'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import DeleteDaySegmentComponent from './DeleteDaySegmentComponent'; +import EditDaySegmentModalComponent from './EditDaySegmentModalComponent'; +import SplitDaySegmentComponent from './SplitDaySegmentComponent'; + +export interface EditDayModalComponentProps { + /** + * Whether the modal is visible or not + */ + show: boolean; + /** + * The day to edit + */ + day: Day; + /** + * Function to run when edit modal closes + */ + handleClose: () => void; +} + +/** + * Defines a modal that allows editing of an existing day and its segments, with options to edit, split, and delete them. + * @param props The properties for the component + * @returns Day edit element + */ +export default function EditDayModalComponent(props: EditDayModalComponentProps) { + const PER_PAGE = 10; + + const translate = useTranslate(); + + const [dayDetails, setDayDetails] = React.useState({ ...props.day }); + + const { data: daySegments = [] } = daySegmentsApi.useGetDaySegmentsByDayIdQuery(props.day.id); + + // Fetch days data (used to check if day name already exists) + const { data: days } = daysApi.useGetDaysQuery(); + + const [editDayMutation, { isLoading: isSaving }] = daysApi.useEditDayMutation(); + + const handleStringChange = (e: React.ChangeEvent) => { + setDayDetails({ ...dayDetails, [e.target.name]: e.target.value }); + }; + + const handleSubmit = () => { + props.handleClose(); + editDayMutation(dayDetails).unwrap() + .then(() => { + showSuccessNotification(translate('day.edit.success')); + }).catch(error => { + showErrorNotification(`${translate('day.edit.error')} ${error}`); + }); + }; + + // Pagination + const [page, setPage] = React.useState(1); + const totalPages = Math.ceil(daySegments.length / PER_PAGE); + const paged = daySegments.slice((page - 1) * PER_PAGE, page * PER_PAGE); + + // State to hold validation message for day name + // This is used to show an error message if the day name is invalid + const [nameValidationMessageId, setNameValidationMessageId] = React.useState(null); + + // Validate the day name to ensure it is not empty and does not already exist + const isNameValid = React.useMemo(() => { + if (dayDetails.name === '') { + setNameValidationMessageId('day.create.name.required'); + return false; + } + if (days?.some(day => day.name.toLowerCase() === dayDetails.name.toLowerCase() && day.id !== dayDetails.id)) { + setNameValidationMessageId('day.create.name.exists'); + return false; + } + + setNameValidationMessageId(null); + return true; + }, [dayDetails]); + + const isDayUnchanged = React.useMemo(() => { + return props.day.name === dayDetails.name && props.day.note === dayDetails.note; + }, [props.day, dayDetails]); + + // State to hold the modal for editing a day segment + const [showEditSegmentModal, setShowEditSegmentModal] = React.useState(false); + const [editSegment, setEditSegment] = React.useState(null); + + // Function to open the edit segment modal + const handleShowEditSegmentModal = (segment: DaySegment) => { + 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: DaySegment) => { + setNoteSegment(segment); + }; + const handleCloseNoteModal = () => { + setNoteSegment(null); + }; + + // Delete day confirmation modal + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [deleteDayMutation, { isLoading: isDeleting }] = daysApi.useDeleteDayMutation(); + + // Function to handle deleting the day + const handleDeleteDay = () => { + setShowDeleteModal(false); + deleteDayMutation({ id: dayDetails.id }).unwrap() + .then(() => { + showSuccessNotification(translate('day.delete.success')); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(`${translate('day.delete.error')} ${error}`); + }); + }; + + return ( + <> + + + + +
+ +
+
+ + + + {/* Name */} + + + + + {nameValidationMessageId && } + + + {/* Note */} + + + + + +
+ {/* Table */} +
+ + + + + + + + + + + + + + + + {paged?.map(seg => ( + + + + + + + + + + + + ))} + +
{seg.startHour} - {seg.endHour}{seg.slope}{seg.intercept} 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.endHour - seg.startHour > 1 && } + + {seg.endHour - seg.startHour > 1 && } + + {/* first segment cannot delete earlier */} + {seg.startHour > 0 && + + } + + {/* last segment cannot delete later */} + {seg.endHour < 24 && + + } +
+ + {/* Pagination */} + {daySegments && daySegments.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 day */} + + + + + + {/* Delete confirmation modal */} + setShowDeleteModal(false)} + actionConfirmText={translate('day.delete.button')} + actionRejectText={translate('cancel')} + /> + + {/* Segment note modal */} + + + {noteSegment?.startHour} - {noteSegment?.endHour} + + + {noteSegment?.note} + + +
+ + {/* Edit Day Segment modal */} + {editSegment && } + + ); +} diff --git a/src/client/app/components/days/EditDaySegmentModalComponent.tsx b/src/client/app/components/days/EditDaySegmentModalComponent.tsx new file mode 100644 index 0000000000..0508fdb62a --- /dev/null +++ b/src/client/app/components/days/EditDaySegmentModalComponent.tsx @@ -0,0 +1,236 @@ +/* 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 { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { DaySegment, UpdateDaySegmentPayload } from '../../types/redux/days'; +import { showErrorNotification } from '../../utils/notifications'; + +interface EditDaySegmentModalComponentProps { + /** + * Whether the modal is visible or not + */ + show: boolean; + + /** + * The day segment to edit + */ + daySegment: DaySegment; + + /** + * Function to run when edit modal closes + */ + handleClose: () => void; +} + +/** + * /** + * Defines a modal that allows editing of an existing Day Segment. + * @param props - The properties for the component + * @returns Day segment edit element + */ +export default function EditDaySegmentModalComponent(props: EditDaySegmentModalComponentProps): React.ReactElement { + const [daySegment, setDaySegment] = React.useState( + { ...props.daySegment, originalStartHour: props.daySegment.startHour, originalEndHour: props.daySegment.endHour } + ); + + // Fetch day segments by day ID to validate start and end hours + const { data: daySegments = [] } = daySegmentsApi.useGetDaySegmentsByDayIdQuery(props.daySegment.dayId); + + const [editDaySegmentMutation, { isLoading: isSaving }] = daySegmentsApi.useEditDaySegmentMutation(); + + const handleStringChange = (e: React.ChangeEvent) => { + setDaySegment({ ...daySegment, [e.target.name]: e.target.value }); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + setDaySegment({ ...daySegment, [e.target.name]: Number(e.target.value) }); + }; + + const handleSubmit = () => { + props.handleClose(); + editDaySegmentMutation(daySegment).unwrap() + .then(() => { + }) + .catch(error => { + showErrorNotification(error); + }); + }; + + // The segment immediately before the current segment, if it exists. + // Used to validate the start hour. + const earlierSegment = React.useMemo(() => { + const segmentIndex = daySegments.findIndex(s => s.id === props.daySegment.id); + if (segmentIndex > 0) { + return daySegments[segmentIndex - 1]; + } + return null; + }, [daySegments, props.daySegment.id]); + + // The segment immediately after the current segment, if it exists. + // Used to validate the end hour. + const laterSegment = React.useMemo(() => { + const segmentIndex = daySegments.findIndex(s => s.id === props.daySegment.id); + if (segmentIndex < daySegments.length - 1) { + return daySegments[segmentIndex + 1]; + } + return null; + }, [daySegments, props.daySegment.id]); + + // Validate the start hour + // It should be a valid hour and not conflict with existing segments + const isStartHourValid = React.useMemo(() => { + if (!Number.isInteger(daySegment.startHour) || daySegment.startHour < 0 || daySegment.startHour >= daySegment.endHour) { + return false; + } + // Check if the start hour does not conflict with existing segments + if (earlierSegment && daySegment.startHour <= earlierSegment.startHour) { + return false; + } + + return true; + }, [daySegment.startHour, daySegment.endHour, earlierSegment]); + + // Validate the end hour + // It should be a valid hour and not conflict with existing segments + const isEndHourValid = React.useMemo(() => { + if (!Number.isInteger(daySegment.endHour) || daySegment.endHour <= daySegment.startHour || daySegment.endHour > 24) { + return false; + } + // Check if the end hour does not conflict with existing segments + if (laterSegment && daySegment.endHour >= laterSegment.endHour) { + return false; + } + + return true; + }, [daySegment.startHour, daySegment.endHour, laterSegment]); + + // Validate the segment as a whole + // It should have valid start and end hours + const isSegmentValid = React.useMemo(() => { + return isStartHourValid && isEndHourValid; + }, [daySegment.startHour, daySegment.endHour]); + + const isSegmentUnchanged = React.useMemo(() => { + return props.daySegment.slope === daySegment.slope && + props.daySegment.intercept === daySegment.intercept && + props.daySegment.startHour === daySegment.startHour && + props.daySegment.endHour === daySegment.endHour && + props.daySegment.note === daySegment.note; + }, [props.daySegment, daySegment]); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Discard changes */} + + {/* Save changes */} + + + + + ); +} diff --git a/src/client/app/components/days/SplitDaySegmentComponent.tsx b/src/client/app/components/days/SplitDaySegmentComponent.tsx new file mode 100644 index 0000000000..43df5ee197 --- /dev/null +++ b/src/client/app/components/days/SplitDaySegmentComponent.tsx @@ -0,0 +1,214 @@ +/* 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 { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { selectDefaultSplitDaySegmentValues } from '../../redux/selectors/adminSelectors'; +import { DaySegment, SplitDaySegmentPayload } from '../../types/redux/days'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; + +interface SplitDaySegmentComponentProps { + /** + * The direction to split the segment + */ + direction: 'earlier' | 'later'; + + /** + * The day segment to split + */ + daySegment: DaySegment; +} + +/** + * Defines a button that opens a modal to split a day 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 SplitDaySegmentComponent(props: SplitDaySegmentComponentProps): React.ReactElement { + const translate = useTranslate(); + + const [splitHour, setSplitHour] = React.useState(-999); + + const defaultSegmentValues = selectDefaultSplitDaySegmentValues(props.daySegment); + + // 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] = daySegmentsApi.useSplitEarlierMutation(); + const [splitLaterMutation] = daySegmentsApi.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.daySegment.startHour && splitHour < props.daySegment.endHour; + + }, [splitHour, props.daySegment, props.direction]); + + const doMutation = () => { + const mutation = props.direction === 'earlier' + ? splitEarlierMutation + : splitLaterMutation; + + handleHideSplitModal(); + mutation({ ...newSegment, dayId: props.daySegment.dayId, splitTime: splitHour }).unwrap() + .then(() => { + showSuccessNotification(translate('day.segments.split.success')); + }) + .catch(error => { + showErrorNotification(error); + }); + }; + + // Handle the split operation + const handleSubmit = () => { + // Show warning modal if slope and intercept are both 0 + if (newSegment.newSlope === 0 && newSegment.newIntercept === 0) { + setWarningMessage(translate('day.slope.intercept.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.daySegment.startHour + 1)) + .replace('{end}', String(props.daySegment.endHour - 1))} + + + + + + + + + + + + + + + + + + + + + {/* Warning modal if slope and intercept are both zero */} + + + + ); +} + diff --git a/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx new file mode 100644 index 0000000000..79a1bd69ef --- /dev/null +++ b/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.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, + FormFeedback, + FormGroup, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row +} from 'reactstrap'; +import MultiSelectComponent from '../MultiSelectComponent'; +import { SelectOption } from '../../types/items'; +import { HolidayInstanceDetails } from '../../types/redux/holidays'; + +interface CreateHolidayInstanceGroupModalComponentProps { + holidayInstances: HolidayInstanceDetails[]; + onCreateHolidayInstanceGroup: (name: string, holidayInstanceIds: number[], note: string) => void; +} + +interface LocationOption extends SelectOption { + location: string; +} + +/** + * Defines the create holiday instance group modal form. + * @param props Available holiday instances and the create handler. + * @returns Holiday instance group create element. + */ +export default function CreateHolidayInstanceGroupModalComponent( + props: CreateHolidayInstanceGroupModalComponentProps +) { + const [showModal, setShowModal] = useState(false); + const [name, setName] = useState(''); + const [selectedLocations, setSelectedLocations] = useState([]); + const [holidayInstanceIds, setHolidayInstanceIds] = useState([]); + const [note, setNote] = useState(''); + + // handle location + // TODO: consider changing how locations are handled to match other pages + const locations = React.useMemo( + () => Array.from(new Set( + props.holidayInstances + .map(holidayInstance => holidayInstance.location.trim()) + .filter(location => location !== '') + )).sort((first, second) => + first.localeCompare(second, undefined, { sensitivity: 'base' })), + [props.holidayInstances] + ); + const locationOptions: LocationOption[] = locations.map((location, index) => ({ + label: location, + value: index, + location + })); + const selectedLocationOptions = locationOptions.filter(option => + selectedLocations.includes(option.location) + ); + + // filter holiday instances based on selected locations + const filteredHolidayInstances = props.holidayInstances.filter(holidayInstance => + selectedLocations.includes(holidayInstance.location.trim()) + ); + const sortedHolidayInstances = React.useMemo( + () => [...filteredHolidayInstances].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [filteredHolidayInstances] + ); + const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ + label: holidayInstance.name, + value: holidayInstance.id + })); + const selectedHolidayInstanceOptions = holidayInstanceOptions.filter(option => + holidayInstanceIds.includes(option.value) + ); + + // handle state of modal + const resetState = () => { + setName(''); + setSelectedLocations([]); + setHolidayInstanceIds([]); + setNote(''); + }; + + const handleShow = () => { + setSelectedLocations(locations.length === 1 ? locations : []); + setShowModal(true); + }; + + const handleClose = () => { + setShowModal(false); + resetState(); + }; + + // handle submit + // TODO: give a warning message and make it so that you are unable to submit if group name already exists + const handleSubmit = () => { + if (holidayInstanceIds.length === 0) { + return; + } + + props.onCreateHolidayInstanceGroup(name.trim(), holidayInstanceIds, note); + setShowModal(false); + resetState(); + }; + + const validName = name.trim() !== ''; + const validHolidayInstanceGroup = validName && holidayInstanceIds.length > 0; + + return ( + <> + {/* show modal button */} + + + + + + + + + {/* Name input*/} + + setName(e.target.value)} + required + invalid={!validName} + /> + + + + + + + + {/* Location select */} + + {locations.length > 1 ? ( + + options={locationOptions} + selectedOptions={selectedLocationOptions} + placeholder='Select locations' + onValuesChange={(newSelectedLocationOptions: LocationOption[]) => { + const updatedLocations = newSelectedLocationOptions.map( + option => option.location + ); + setSelectedLocations(updatedLocations); + setHolidayInstanceIds(currentIds => currentIds.filter(id => { + const holidayInstance = props.holidayInstances.find( + instance => instance.id === id + ); + return holidayInstance !== undefined + && updatedLocations.includes(holidayInstance.location.trim()); + })); + }} + /> + ) : ( + + + + )} + + + + {/* The holiday instances in this group */} + {/* TODO: Consider changing how holiday rates selected. Should you have to select + a location first or should location only be used to filter? Consider making it so that + all locations are displayed and filtered when location is selected */} + {/* TODO: Make multiselect border highlighted when invalid */} + + + 0 + ? 'Select holiday instances' + : 'Select a location first' + } + onValuesChange={(newSelectedHolidayOptions: SelectOption[]) => { + const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( + holidayInstance => holidayInstance.value + ); + setHolidayInstanceIds(updatedHolidayInstanceIds); + }} + /> + {holidayInstanceIds.length === 0 && ( + + + + )} + + + + + {/* Note input */} + + setNote(e.target.value)} + /> + + + + {/* Modal footer with buttons */} + + + + + + ); +} diff --git a/src/client/app/components/holiday-instance-groups/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday-instance-groups/EditHolidayInstanceGroupModalComponent.tsx new file mode 100644 index 0000000000..4b7ceaa668 --- /dev/null +++ b/src/client/app/components/holiday-instance-groups/EditHolidayInstanceGroupModalComponent.tsx @@ -0,0 +1,321 @@ +/* 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 { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + Button, + Col, + Container, + FormFeedback, + FormGroup, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row +} from 'reactstrap'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import MultiSelectComponent from '../MultiSelectComponent'; +import { SelectOption } from '../../types/items'; +import { HolidayInstanceDetails, HolidayInstanceGroup } from '../../types/redux/holidays'; +import { useTranslate } from '../../redux/componentHooks'; + +interface EditHolidayInstanceGroupModalComponentProps { + show: boolean; + holidayInstanceGroup: HolidayInstanceGroup; + holidayInstances: HolidayInstanceDetails[]; + handleShow: () => void; + handleClose: () => void; + onEditHolidayInstanceGroup: ( + holidayInstanceGroupId: number, + name: string, + holidayInstanceIds: number[], + note: string + ) => void; + onDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; +} + +interface LocationOption extends SelectOption { + location: string; +} + +/** + * Defines the edit holiday instance group modal form. + * @param props Existing group data, available holiday instances, and action handlers. + * @returns Holiday instance group edit element. + */ +export default function EditHolidayInstanceGroupModalComponent( + props: EditHolidayInstanceGroupModalComponentProps +) { + const translate = useTranslate(); + const [name, setName] = useState(props.holidayInstanceGroup.name ?? ''); + const [selectedLocations, setSelectedLocations] = useState([]); + const [holidayInstanceIds, setHolidayInstanceIds] = useState( + props.holidayInstanceGroup.holidayInstanceIds + ); + const [note, setNote] = useState(props.holidayInstanceGroup.note); + const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false); + + // handle locations + // TODO: consider changing how locations are handled to match other pages + const locations = React.useMemo( + () => Array.from(new Set( + props.holidayInstances + .map(holidayInstance => holidayInstance.location.trim()) + .filter(location => location !== '') + )).sort((first, second) => + first.localeCompare(second, undefined, { sensitivity: 'base' })), + [props.holidayInstances] + ); + const locationOptions: LocationOption[] = locations.map((location, index) => ({ + label: location, + value: index, + location + })); + const selectedLocationOptions = locationOptions.filter(option => + selectedLocations.includes(option.location) + ); + + // filter holiday instances based on selected locations and sort by name + const filteredHolidayInstances = props.holidayInstances.filter(holidayInstance => + selectedLocations.includes(holidayInstance.location.trim()) + ); + const sortedHolidayInstances = React.useMemo( + () => [...filteredHolidayInstances].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [filteredHolidayInstances] + ); + + // map holiday instances to select options and filter by selected IDs + const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ + label: holidayInstance.name, + value: holidayInstance.id + })); + const selectedHolidayInstanceOptions = holidayInstanceOptions.filter(option => + holidayInstanceIds.includes(option.value) + ); + const validName = name.trim() !== ''; + const validHolidayInstanceGroup = validName && holidayInstanceIds.length > 0; + + // reset state to initial values when modal is opened + const resetState = React.useCallback(() => { + const groupLocations = Array.from(new Set( + props.holidayInstanceGroup.holidayInstanceIds + .map(id => props.holidayInstances.find(instance => instance.id === id)?.location.trim()) + .filter((location): location is string => Boolean(location)) + )); + setName(props.holidayInstanceGroup.name ?? ''); + setSelectedLocations(locations.length === 1 ? locations : groupLocations); + setHolidayInstanceIds(props.holidayInstanceGroup.holidayInstanceIds); + setNote(props.holidayInstanceGroup.note); + }, [locations, props.holidayInstanceGroup, props.holidayInstances]); + + useEffect(() => { + if (props.show) { + resetState(); + } + }, [props.show, resetState]); + + // handle modal close + const handleClose = () => { + props.handleClose(); + resetState(); + }; + + // handle save changes + const handleSaveChanges = () => { + if (!validHolidayInstanceGroup) { + return; + } + + props.handleClose(); + props.onEditHolidayInstanceGroup( + props.holidayInstanceGroup.id, + name.trim(), + holidayInstanceIds, + note + ); + }; + + // handle delete confirmation modal + const handleDeleteConfirmationModalOpen = () => { + props.handleClose(); + setShowDeleteConfirmationModal(true); + }; + + const handleDeleteConfirmationModalClose = () => { + setShowDeleteConfirmationModal(false); + props.handleShow(); + }; + + const handleDeleteHolidayInstanceGroup = () => { + setShowDeleteConfirmationModal(false); + props.onDeleteHolidayInstanceGroup?.(props.holidayInstanceGroup.id); + }; + + return ( + <> + + + + + + + + + {/* Name input */} + + + setName(e.target.value)} + required + invalid={!validName} + /> + + + + + + + + {/* Location input */} + + + {locations.length > 1 ? ( + + options={locationOptions} + selectedOptions={selectedLocationOptions} + placeholder='Select locations' + onValuesChange={(newSelectedLocationOptions: LocationOption[]) => { + const updatedLocations = newSelectedLocationOptions.map( + option => option.location + ); + setSelectedLocations(updatedLocations); + setHolidayInstanceIds(currentIds => currentIds.filter(id => { + const holidayInstance = props.holidayInstances.find( + instance => instance.id === id + ); + return holidayInstance !== undefined + && updatedLocations.includes(holidayInstance.location.trim()); + })); + }} + /> + ) : ( + + + + )} + + + + {/* The holiday instances in this group */} + {/* TODO: Consider changing how holiday rates selected. Should you have to select + a location first or should location only be used to filter? Consider making it so that + all locations are displayed and filtered when location is selected */} + {/* TODO: Make multiselect border highlighted when invalid */} + + + 0 + ? 'Select holiday instances' + : 'Select a location first' + } + onValuesChange={(newSelectedHolidayOptions: SelectOption[]) => { + const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( + holidayInstance => holidayInstance.value + ); + setHolidayInstanceIds(updatedHolidayInstanceIds); + }} + /> + {holidayInstanceIds.length === 0 && ( + + + + )} + + + + + {/* Note input */} + + + setNote(e.target.value)} + /> + + + + {/* Modal footer with action buttons */} + + {props.onDeleteHolidayInstanceGroup && ( + + )} + + + + + + ); +} diff --git a/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx new file mode 100644 index 0000000000..0174d2af1b --- /dev/null +++ b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx @@ -0,0 +1,145 @@ +/* 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 { + useGetHolidayInstancesWithDetailsQuery +} from '../../redux/api/holidayInstancesApi'; +import { + stableEmptyHolidayInstanceGroups, + useAddHolidayInstanceGroupMutation, + useDeleteHolidayInstanceGroupMutation, + useEditHolidayInstanceGroupMutation, + useGetHolidayInstanceGroupsQuery +} from '../../redux/api/holidayInstanceGroupsApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { titleStyle } from '../../styles/modalStyle'; +import CreateHolidayInstanceGroupModalComponent from './CreateHolidayInstanceGroupModalComponent'; +import HolidayInstanceGroupViewComponent from './HolidayInstanceGroupViewComponent'; +import { HolidayInstanceDetails } from '../../types/redux/holidays'; +import { + showErrorNotification, + showSuccessNotification +} from '../../utils/notifications'; + +const stableEmptyHolidayInstanceDetails: HolidayInstanceDetails[] = []; + +/** + * Defines the holiday instance group card page. + * @returns Holiday instance group page element. + */ +export default function HolidayInstanceGroupComponent() { + // The route stops you from getting to this page if not an admin. + const translate = useTranslate(); + const { + data: holidayInstances = stableEmptyHolidayInstanceDetails, + isLoading: holidayInstancesLoading, + error: holidayInstancesError + } = useGetHolidayInstancesWithDetailsQuery(); + const { + data: holidayInstanceGroups = stableEmptyHolidayInstanceGroups, + isLoading: holidayInstanceGroupsLoading, + error: holidayInstanceGroupsError + } = useGetHolidayInstanceGroupsQuery(); + const [addHolidayInstanceGroup] = useAddHolidayInstanceGroupMutation(); + const [editHolidayInstanceGroup] = useEditHolidayInstanceGroupMutation(); + const [deleteHolidayInstanceGroup] = useDeleteHolidayInstanceGroupMutation(); + + // handle create holiday instance group + const handleCreateHolidayInstanceGroup = async ( + name: string, + holidayInstanceIds: number[], + note: string + ) => { + try { + await addHolidayInstanceGroup({ name, holidayInstanceIds, note }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.create.success')); + } catch (error) { + showErrorNotification( + `${translate('holiday.instance.group.create.failure')}` + ); + } + }; + + // handle update holiday instance group + const handleUpdateHolidayInstanceGroup = async ( + holidayInstanceGroupId: number, + name: string, + holidayInstanceIds: number[], + note: string + ) => { + try { + await editHolidayInstanceGroup({ + id: holidayInstanceGroupId, + name, + holidayInstanceIds, + note + }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.edit.success')); + } catch (error) { + showErrorNotification( + `${translate('holiday.instance.group.edit.failure')}` + ); + } + }; + + // handle delete holiday instance group + const handleDeleteHolidayInstanceGroup = async (holidayInstanceGroupId: number) => { + try { + await deleteHolidayInstanceGroup({ id: holidayInstanceGroupId }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.delete.success')); + } catch (error) { + showErrorNotification( + `${translate('holiday.instance.group.delete.failure')}` + ); + } + }; + + const isLoading = holidayInstancesLoading || holidayInstanceGroupsLoading; + const loadError = holidayInstancesError || holidayInstanceGroupsError; + + return ( +
+
+

+ +

+ {/* Display error message if there is a load error */} + {loadError && ( +
+ +
+ )} + {/* Create holiday instance group button */} +
+ +
+ {/* Display holiday instance groups*/} +
+ {isLoading ? ( +
+ +
+ ) : [...holidayInstanceGroups] + .sort((firstGroup, secondGroup) => firstGroup.id - secondGroup.id) + .map(holidayInstanceGroup => ( + + ))} +
+
+
+ ); +} diff --git a/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupViewComponent.jsx new file mode 100644 index 0000000000..860a555943 --- /dev/null +++ b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupViewComponent.jsx @@ -0,0 +1,95 @@ +/* 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 '../../styles/card-page.css'; +import EditHolidayInstanceGroupModalComponent from './EditHolidayInstanceGroupModalComponent'; + +/** + * Defines a holiday instance group information card. + * @param {object} props Component properties. + * @param {{id: number, name: string, holidayInstanceIds: number[], note: string}} props.holidayInstanceGroup Group data. + * @param {Array<{id: number, name: string, location: string}>} props.holidayInstances Available holiday instances. + * @param {(id: number, name: string, holidayInstanceIds: number[], note: string) => void} props.onEditHolidayInstanceGroup Edit handler. + * @param {(id: number) => void} [props.onDeleteHolidayInstanceGroup] Optional delete handler. + * @returns Holiday instance group card element. + */ +export default function HolidayInstanceGroupViewComponent(props) { + const [showEditModal, setShowEditModal] = useState(false); + + const handleShow = () => { + setShowEditModal(true); + }; + + const handleClose = () => { + setShowEditModal(false); + }; + + // Get the holiday instances that are part of this group. + const selectedHolidayInstances = props.holidayInstanceGroup.holidayInstanceIds + .map(holidayInstanceId => props.holidayInstances.find( + holidayInstance => + Number(holidayInstance.id) === Number(holidayInstanceId) + )) + .filter(holidayInstance => holidayInstance !== undefined); + + // Display the note, truncated if it is too long. + const note = props.holidayInstanceGroup.note ?? ''; + const displayedNote = note.length > 30 ? `${note.slice(0, 29)}...` : note; + + return ( +
+
+ {props.holidayInstanceGroup.name} +
+ +
+ + + + {/* Display the names of the holiday instances in this group. If there are more than one, + display the first one and indicate how many more there are. */} + {selectedHolidayInstances.length > 0 ? ( + + {selectedHolidayInstances[0].name} + {selectedHolidayInstances.length > 1 && + `...(${selectedHolidayInstances.length})`} + + ) : ( + + + + )} +
+ +
+ {' '} + {displayedNote} +
+ + {/* Display the edit button and modal. */} +
+ + +
+
+ ); +} diff --git a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx new file mode 100644 index 0000000000..1988fdcd57 --- /dev/null +++ b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx @@ -0,0 +1,489 @@ +/* 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 { useMemo, useState } from 'react'; +import Select from 'react-select'; +import { + Button, Col, Container, FormFeedback, FormGroup, Input, Label, + Modal, ModalBody, ModalFooter, ModalHeader, Row +} from 'reactstrap'; +import { stableEmptyDays, useGetDaysQuery } from '../../redux/api/daysApi'; +import { + stableEmptyHolidayInstances, + useAddHolidayInstanceMutation, + useDeleteHolidayInstanceMutation, + useEditHolidayInstanceMutation, + useGetHolidayInstancesQuery +} from '../../redux/api/holidayInstancesApi'; +import { stableEmptyHolidays, useGetHolidaysQuery } from '../../redux/api/holidaysApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { CreateHolidayInstancePayload, Holiday, HolidayInstance } from '../../types/redux/holidays'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; + +// TODO: A region selector should auto-populate the base-holiday dropdown from +// the selected region's holidays once region support exists; until then the +// dropdown lists all holidays with their location in parentheses. + +// Sentinel values for "nothing selected yet" dropdown state, matching the +// -999 placeholder convention used in the conversion modals. +const NO_HOLIDAY = -999; +const NO_PATTERN = -999; + +// Card clipping: names clip short with the critical information first; notes +// clip at the note/log convention length. +const MAX_NAME_LENGTH = 15; +const MAX_NOTE_LENGTH = 30; + +// The holiday_instance.name column is VARCHAR(50); cap the input so the +// database limit can never be hit (avoids surfacing a database error). +const MAX_NAME_DB_LENGTH = 50; +const clip = (text: string, max: number) => + text.length > max ? `${text.slice(0, max)} ...` : text; + +// Holidays display as "Name (Location)" since the same-named holiday in a +// different location is a different holiday. +const holidayLabel = (holiday: Holiday) => `${holiday.name} (${holiday.location})`; + +// Default (empty) create draft. Discard / open-create resets to this. +const defaultValues: CreateHolidayInstancePayload = { + name: '', + holidayId: NO_HOLIDAY, + dayPatternId: NO_PATTERN, + note: '' +}; + +type ModalMode = 'create' | 'edit'; + +interface ConfirmModalProps { + isOpen: boolean; + message: string; + confirmText: string; + // 'danger' for delete, 'primary'/'secondary' otherwise. + confirmColor: string; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * TODO: replace with the shared confirmation component + * (ConfirmActionModalComponent on the development branch) when available here. + * @param props Message, confirm button text/color, and confirm/cancel callbacks + * @returns A small yes/no confirmation modal + */ +function ConfirmModal(props: ConfirmModalProps) { + const translate = useTranslate(); + return ( + + {props.message} + + + + + + ); +} + +/** + * Holiday rate page: a Create button at the top opens a create modal; every + * saved instance renders as a card with a Details/Edit modal that can also + * delete it. + * @returns Holiday rate page element + */ +export default function HolidayInstancePage() { + const translate = useTranslate(); + + /* Global state (server-backed via RTK Query). */ + const { data: holidays = stableEmptyHolidays } = useGetHolidaysQuery(); + const { data: dayPatterns = stableEmptyDays } = useGetDaysQuery(); + const { data: instances = stableEmptyHolidayInstances } = useGetHolidayInstancesQuery(); + const [addHolidayInstance] = useAddHolidayInstanceMutation(); + const [editHolidayInstance] = useEditHolidayInstanceMutation(); + const [deleteHolidayInstance] = useDeleteHolidayInstanceMutation(); + + const sortedHolidays = useMemo( + () => [...holidays].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())), + [holidays] + ); + + /* Local (modal) state - untied to global state until Save. */ + const [showModal, setShowModal] = useState(false); + const [modalMode, setModalMode] = useState('create'); + // The id of the instance being edited (null in create mode). + const [editingId, setEditingId] = useState(null); + // Working draft the modal edits. Reset on open/discard. + const [draft, setDraft] = useState(defaultValues); + // Snapshot of the draft at open, used for the unsaved-changes check. + const [initialDraft, setInitialDraft] = useState(defaultValues); + // Confirmation sub-modals: unsaved-changes warning and delete guard. + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + // Warn (non-blocking) when the base holiday changes after a name was + // already entered, since the name may no longer match. + const [showBaseChangeWarning, setShowBaseChangeWarning] = useState(false); + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipHolidayRateView: 'help.admin.holidayrateview' + }; + + /* Lookups (ids are internal; only names are shown to the user). */ + const holidayName = (id: number) => { + const holiday = holidays.find(item => item.id === id); + return holiday === undefined ? translate('holiday.base.unknown') : holidayLabel(holiday); + }; + const patternName = (id: number) => + dayPatterns.find(p => p.id === id)?.name ?? ''; + + // Searchable dropdown options for the base holiday. + // TODO: swap to the shared SingleSelectComponent if it fits. + const holidayOptions = useMemo( + () => sortedHolidays.map(h => ({ value: h.id, label: holidayLabel(h) })), + [sortedHolidays] + ); + const selectedHolidayOption = + holidayOptions.find(o => o.value === draft.holidayId) ?? null; + + /* Validation. Required fields red-outline until valid. */ + const isNameInvalid = draft.name.trim().length === 0; + const isHolidayInvalid = draft.holidayId === NO_HOLIDAY; + const isPatternInvalid = draft.dayPatternId === NO_PATTERN; + const isValid = !isNameInvalid && !isHolidayInvalid && !isPatternInvalid; + + // TODO: sweep existing instances and warn/stop on duplicates before save. + // The database enforces UNIQUE(name) and UNIQUE(holidayId, dayPatternId); + // the UI should catch both instead of surfacing a database error. + + // In create mode initialDraft === defaultValues, so this doubles as the + // "did they type anything at all" check for the unsaved-changes warning. + const hasUnsavedChanges = + draft.name !== initialDraft.name || + draft.holidayId !== initialDraft.holidayId || + draft.dayPatternId !== initialDraft.dayPatternId || + draft.note !== initialDraft.note; + + // Save is gated on validity AND, in edit mode, on an actual change being + // made (the Save button only lights up once something really changes). + const canSave = isValid && (modalMode === 'create' || hasUnsavedChanges); + + /* Handlers */ + const openCreate = () => { + setModalMode('create'); + setEditingId(null); + setDraft(defaultValues); + setInitialDraft(defaultValues); + setShowModal(true); + }; + + const openEdit = (instance: HolidayInstance) => { + const asDraft: CreateHolidayInstancePayload = { + name: instance.name, + holidayId: instance.holidayId, + dayPatternId: instance.dayPatternId, + note: instance.note ?? '' + }; + setModalMode('edit'); + setEditingId(instance.id); + setDraft(asDraft); + setInitialDraft(asDraft); + setShowModal(true); + }; + + // Unconditional close + reset. Only reachable via Save, or after the user + // confirms they want to lose their changes. + const doClose = () => { + setShowModal(false); + setShowBaseChangeWarning(false); + setShowCloseConfirm(false); + setShowDeleteConfirm(false); + setEditingId(null); + setDraft(defaultValues); + setInitialDraft(defaultValues); + }; + + // Close attempt (backdrop click, X, or Discard). Closing with no changes is + // safe - close silently. With changes, warn about losing work. + // TODO: align with the shared unsaved-changes handling used on newer pages + // (hasUnsavedChanges flow on the development branch) when available here. + const attemptClose = () => { + if (hasUnsavedChanges) { + setShowCloseConfirm(true); + } else { + doClose(); + } + }; + + const handleSave = () => { + if (!canSave) { + return; + } + if (modalMode === 'create') { + addHolidayInstance(draft).unwrap() + .then(() => showSuccessNotification(translate('holiday.rate.create.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.create.failure') + error)); + } else if (editingId !== null) { + // The edit route requires holidayId even though the UI locks the base + // holiday after create, so the full draft is sent. + editHolidayInstance({ id: editingId, ...draft }).unwrap() + .then(() => showSuccessNotification(translate('holiday.rate.edit.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.edit.failure') + error)); + } + doClose(); + }; + + // TODO: before deleting, check whether a holiday group references this + // instance and block with a friendly message instead of surfacing the + // foreign-key database error. + const handleDeleteConfirmed = () => { + // Delete is only reachable from the edit modal, so editingId is set; + // this guards the type narrowing. + if (editingId === null) { + return; + } + deleteHolidayInstance({ id: editingId }).unwrap() + .then(() => showSuccessNotification(translate('holiday.rate.delete.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.delete.failure') + error)); + doClose(); + }; + + return ( +
+ + +

+ {translate('holiday.rates')} +
+ +
+

+ +
+ +
+ + {/* Instance cards */} +
+ {instances.map(instance => ( +
+
+ {clip(instance.name, MAX_NAME_LENGTH)} +
+
+ {translate('holiday.base')} {holidayName(instance.holidayId)} +
+
+ {translate('day.pattern')} {patternName(instance.dayPatternId)} +
+ {(instance.note ?? '').trim().length > 0 && ( +
+ {translate('note')} {clip(instance.note ?? '', MAX_NOTE_LENGTH)} +
+ )} +
+ +
+
+ ))} + {instances.length === 0 && ( +

{translate('holiday.rate.none')}

+ )} +
+
+ + {/* Create / Edit modal */} + + + {modalMode === 'create' ? translate('holiday.rate.create') : translate('holiday.rate.edit')} + + + + + + {/* Base holiday: searchable combobox on create; locked on + edit - changing the base holiday invalidates the record, + so delete and recreate instead. */} + + + {modalMode === 'create' ? ( + <> + + )} + + + + {/* Instance name - required */} + + + + setDraft(d => ({ ...d, name: e.target.value })) + } + invalid={isNameInvalid} + /> + {translate('holiday.rate.name.required')} + + + + + {/* Day pattern - required. Placeholder option uses the hidden/ + disabled sentinel option convention. */} + + + + setDraft(d => ({ ...d, dayPatternId: Number(e.target.value) })) + } + invalid={isPatternInvalid} + > + + {dayPatterns.map(pattern => ( + + ))} + + {translate('day.pattern.required')} + + + {/* Note - optional */} + + + setDraft(d => ({ ...d, note: e.target.value }))} + /> + + + + + {modalMode === 'edit' && ( + + )} + + + + + + {/* Unsaved-changes warning */} + setShowCloseConfirm(false)} + /> + + {/* Delete guard */} + setShowDeleteConfirm(false)} + /> +
+ ); +} + +/* Local style objects. + TODO: candidates for styles/modalStyle if other pages want them. */ + +const subtitleStyle: React.CSSProperties = { + fontSize: '1rem', + color: 'gray', + fontWeight: 500 +}; + +const createRowStyle: React.CSSProperties = { + margin: '10px 0' +}; + +// Mimics Bootstrap's .is-invalid red outline on the react-select control so +// the required-field cue matches the reactstrap inputs. +const invalidSelectStyles = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: (base: any) => ({ + ...base, + borderColor: '#dc3545', + '&:hover': { borderColor: '#dc3545' } + }) +}; diff --git a/src/client/app/components/holiday/HolidayPage.tsx b/src/client/app/components/holiday/HolidayPage.tsx new file mode 100644 index 0000000000..3658ff7459 --- /dev/null +++ b/src/client/app/components/holiday/HolidayPage.tsx @@ -0,0 +1,310 @@ +/* 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 { Alert, Button, Col, FormFeedback, FormGroup, Input, Label, Row } from 'reactstrap'; +import { + stableEmptyHolidays, + useGetHolidaysQuery, + useGetHolidayLocationsQuery, + useRefreshHolidaysMutation +} from '../../redux/api/holidaysApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import HolidayViewComponent from './HolidayViewComponent'; + +/** + * Defines the holiday import page. + * @returns Holiday import page element + */ +export default function HolidayPage() { + const translate = useTranslate(); + + const [countryCode, setCountryCode] = React.useState(''); + const [stateCode, setStateCode] = React.useState(''); + const [regionCode, setRegionCode] = React.useState(''); + const [year, setYear] = React.useState( + String(new Date().getFullYear()) + ); + // Get saved holidays, location options, and the import/update function from RTK Query. + const [refreshHolidays, { isLoading }] = useRefreshHolidaysMutation(); + const { data: holidays = stableEmptyHolidays } = useGetHolidaysQuery(); + + const { data: locationData } = useGetHolidayLocationsQuery({ + country: countryCode, + state: stateCode + }); + + // Validate the required import fields and disable importing until they are valid. + const isCountryValid = countryCode.length > 0; + const isYearValid = year.trim().length > 0 && Number.isInteger(Number(year)); + const isImportValid = isCountryValid && isYearValid; + + // The location endpoint returns countries, states, and regions in one response. + // TODO: These simple lookups probably do not need useMemo. Convert them to the + // usual OED/RTK style by reading locationData directly. + const countries = React.useMemo(() => { + if (locationData === undefined) { + return []; + } + + return locationData.countries; + }, [locationData]); + + const states = React.useMemo(() => { + if (locationData === undefined) { + return []; + } + + return locationData.states; + }, [locationData]); + + const regions = React.useMemo(() => { + if (locationData === undefined) { + return []; + } + + return locationData.regions; + }, [locationData]); + + // Match saved holidays to the exact location and year, then order them by date and name. + const selectedHolidays = React.useMemo(() => { + if (countryCode.length === 0) { + return []; + } + + const selectedYear = Number(year); + + if (!Number.isInteger(selectedYear)) { + return []; + } + + const locationParts = [countryCode]; + + if (stateCode.length > 0) { + locationParts.push(stateCode); + } + + if (regionCode.length > 0) { + locationParts.push(regionCode); + } + + const selectedLocation = locationParts.join('-'); + + return holidays + .filter(holiday => { + if (holiday.location !== selectedLocation) { + return false; + } + + return Number(holiday.startDate.slice(0, 4)) === selectedYear; + }) + .sort((first, second) => { + const dateComparison = first.startDate.localeCompare(second.startDate); + + if (dateComparison !== 0) { + return dateComparison; + } + + return first.name.localeCompare(second.name); + }); + }, [countryCode, stateCode, regionCode, year, holidays]); + + // Clear child selections when the user changes a parent location. + const handleCountryChange = (event: React.ChangeEvent) => { + setCountryCode(event.target.value); + setStateCode(''); + setRegionCode(''); + }; + + const handleStateChange = (event: React.ChangeEvent) => { + setStateCode(event.target.value); + setRegionCode(''); + }; + + const handleRegionChange = (event: React.ChangeEvent) => { + setRegionCode(event.target.value); + }; + + const handleYearChange = (event: React.ChangeEvent) => { + setYear(event.target.value); + }; + + // Import or update holidays for the selected location and year. + // RTK Query refreshes the saved holiday list after a successful request. + const handleImport = () => { + if (!isCountryValid) { + showErrorNotification(translate('holiday.import.country.required')); + return; + } + + if (!isYearValid) { + showErrorNotification(translate('holiday.year.invalid')); + return; + } + + const selectedYear = Number(year); + + refreshHolidays({ + country: countryCode, + state: stateCode, + region: regionCode, + year: selectedYear + }).unwrap() + .then(result => { + showSuccessNotification(`${translate('holiday.import.success')}${result.total}`); + }) + .catch(error => { + showErrorNotification(`${translate('holiday.import.failure')}${error}`); + }); + }; + + // Display saved holidays for the current selection or an empty message. + let holidayContent: React.ReactNode = null; + + if (countryCode.length > 0 && selectedHolidays.length === 0) { + holidayContent = ; + } + + if (selectedHolidays.length > 0) { + holidayContent = ( +
+ {selectedHolidays.map(holiday => ( + + ))} +
+ ); + } + + return ( +
+ +
+

+ +
+ +
+

+ + + + + + + + + + {countries.map(country => ( + + ))} + + + + + + + + + + + + {states.map(state => ( + + ))} + + + + + + + + + {regions.map(region => ( + + ))} + + + + + + + + + + + + + + + + + + +

+ +

+ {holidayContent} +
+
+ ); +} diff --git a/src/client/app/components/holiday/HolidayViewComponent.tsx b/src/client/app/components/holiday/HolidayViewComponent.tsx new file mode 100644 index 0000000000..1e08ab07d2 --- /dev/null +++ b/src/client/app/components/holiday/HolidayViewComponent.tsx @@ -0,0 +1,39 @@ +/* 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 { Holiday } from '../../types/redux/holidays'; +import { useTranslate } from '../../redux/componentHooks'; +import '../../styles/card-page.css'; + +interface HolidayViewComponentProps { + holiday: Holiday; +} + +/** + * Displays a saved holiday as card style. + * @param props Holiday to display + * @returns Saved holiday card + */ +export default function HolidayViewComponent(props: HolidayViewComponentProps) { + const translate = useTranslate(); + + return ( +
+
+ {props.holiday.name} +
+
+ {props.holiday.startDate} +
+
+ {props.holiday.location} +
+
+ {translate(`holiday.type.${props.holiday.type}`)} +
+
+ ); +} diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx new file mode 100644 index 0000000000..3a4afd7b7e --- /dev/null +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -0,0 +1,281 @@ +/* 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, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row, Table } from 'reactstrap'; +import { daysApi } from '../../redux/api/daysApi'; +import { useGetHolidayInstanceGroupsQuery } from '../../redux/api/holidayInstanceGroupsApi'; +import { weeksApi, WeekWithHolidayRateGroup } from '../../redux/api/weeksApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectDefaultCreateWeekValues } from '../../redux/selectors/adminSelectors'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { LocaleDataKey } from '../../translations/data'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; + +/** + * Defines a button that opens a modal to create a new weekly conversion pattern. + * @returns Weekly pattern create element + */ +export default function CreateWeekModalComponent(): React.ReactElement { + const translate = useTranslate(); + + // State to control modal visibility + const [showModal, setShowModal] = React.useState(false); + + // Fetch days data + const { data: days = [], isFetching: isFetchingDays } = daysApi.useGetDaysQuery(); + + // Sort days by day name to make the dropdown more user-friendly + const sortedDays = React.useMemo(() => { + if (!days) { + return []; + } + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); + }, [days]); + + // Fetch weeks data (used to check if week name already exists) + const { data: weeks } = weeksApi.useGetWeeksQuery(); + + // Fetch the optional holiday rate groups. + const { + data: holidayInstanceGroups = [], + isFetching: isFetchingHolidayInstanceGroups + } = useGetHolidayInstanceGroupsQuery(); + const sortedHolidayInstanceGroups = React.useMemo( + () => [...holidayInstanceGroups].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [holidayInstanceGroups] + ); + + const [addWeekMutation, { isLoading: isSaving }] = weeksApi.useAddWeekMutation(); + + // Default values for the week creation form + const defaultValues = useAppSelector(selectDefaultCreateWeekValues); + + // State to hold the week details being created. Initialized with default values. + const [weekDetails, setWeekDetails] = React.useState>({ + ...defaultValues + }); + + const handleShowModal = () => setShowModal(true); + const handleCloseModal = () => { + setShowModal(false); + resetState(); + }; + + const handleStringChange = (e: React.ChangeEvent) => { + setWeekDetails({ ...weekDetails, [e.target.name]: e.target.value }); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + setWeekDetails({ ...weekDetails, [e.target.name]: Number(e.target.value) }); + }; + + const handleHolidayRateGroupChange = (e: React.ChangeEvent) => { + setWeekDetails({ + ...weekDetails, + holidayInstanceGroupId: e.target.value === '' ? null : Number(e.target.value) + }); + }; + + // Function to handle form submission + // Validates the week details and submits them to the API + const handleSubmit = () => { + setShowModal(false); + addWeekMutation(weekDetails).unwrap() + .then(() => { + showSuccessNotification(translate('week.create.success')); + }) + .catch(error => { + showErrorNotification(translate('week.create.failure') + error); + }); + resetState(); + }; + + // Function to reset the week details to default values. Called when modal is closed. + const resetState = () => { + setWeekDetails({ + ...defaultValues + }); + }; + + // State to hold validation message for week name + // This is used to show an error message if the week name is invalid + const [nameValidationMessageId, setNameValidationMessageId] = React.useState(null); + + // Validate the week name to ensure it is not empty and does not already exist + const isWeekNameValid = React.useMemo(() => { + if (weekDetails.name === '') { + setNameValidationMessageId('error.required'); + return false; + } + + if (weeks?.some(week => week.name.toLowerCase() === weekDetails.name.toLowerCase())) { + setNameValidationMessageId('week.validation.name.exists'); + return false; + } + + setNameValidationMessageId(null); + return true; + }, [weekDetails.name, weeks]); + + // Validate the week details to ensure all days are selected and the week name is valid + // This is used to enable/disable the submit button + const isWeekValid = React.useMemo(() => { + return isWeekNameValid && + weekDetails.sunday !== -999 && + weekDetails.monday !== -999 && + weekDetails.tuesday !== -999 && + weekDetails.wednesday !== -999 && + weekDetails.thursday !== -999 && + weekDetails.friday !== -999 && + weekDetails.saturday !== -999; + }, [weekDetails, isWeekNameValid]); + + return ( + <> + {/* Show create modal button */} + + + + + + +
+ +
+
+ + + {/* Name */} + + + + + + + {nameValidationMessageId && } + + + + + {/* Note */} + + + + + + + + + {/* Optional holiday rate group */} + + + + + + + {sortedHolidayInstanceGroups.map(group => ( + + ))} + + + + + {/* Days */} + + + + + + + + + + + {(['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const).map(day => ( + + + + + ))} + +
+ + + + {sortedDays?.map(day => ( + + ))} + +
+ +
+
+
+ + {/* Cancel button */} + + {/* Submit button */} + + +
+ + ); +} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx new file mode 100644 index 0000000000..230a4a3f03 --- /dev/null +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -0,0 +1,299 @@ +/* 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, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row, Table } from 'reactstrap'; +import { daysApi } from '../../redux/api/daysApi'; +import { useGetHolidayInstanceGroupsQuery } from '../../redux/api/holidayInstanceGroupsApi'; +import { weeksApi, WeekWithHolidayRateGroup } from '../../redux/api/weeksApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { LocaleDataKey } from '../../translations/data'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; + +interface EditWeekModalComponentProps { + /** + * Whether the modal is visible or not + */ + show: boolean; + + /** + * The week to edit + */ + week: WeekWithHolidayRateGroup; + + /** + * Function to run when edit modal closes + */ + handleClose: () => void; +} + +/** + * Defines a modal that allows editing of an existing weekly conversion pattern. + * @param props - The properties for the component + * @returns Weekly pattern edit element + */ +export default function EditWeekModalComponent(props: EditWeekModalComponentProps): React.ReactElement { + const translate = useTranslate(); + + // State to hold the week details being edited. Initialized with the week passed in through props + const [weekDetails, setWeekDetails] = React.useState({ ...props.week }); + + // Fetch days data + const { data: days, isFetching: isFetchingDays } = daysApi.useGetDaysQuery(); + + // Sort days by day name to make the dropdown more user-friendly + const sortedDays = React.useMemo(() => { + if (!days) { + return []; + } + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); + }, [days]); + + // Fetch weeks data (used to check if week name already exists) + const { data: weeks } = weeksApi.useGetWeeksQuery(); + + // Fetch the optional holiday rate groups. + const { + data: holidayInstanceGroups = [], + isFetching: isFetchingHolidayInstanceGroups + } = useGetHolidayInstanceGroupsQuery(); + const sortedHolidayInstanceGroups = React.useMemo( + () => [...holidayInstanceGroups].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [holidayInstanceGroups] + ); + + const [editWeekMutation, { isLoading: isSaving }] = weeksApi.useEditWeekMutation(); + + const resetState = () => { + setWeekDetails({ ...props.week }); + }; + + const handleStringChange = (e: React.ChangeEvent) => { + setWeekDetails({ ...weekDetails, [e.target.name]: e.target.value }); + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + setWeekDetails({ ...weekDetails, [e.target.name]: Number(e.target.value) }); + }; + + const handleHolidayRateGroupChange = (e: React.ChangeEvent) => { + setWeekDetails({ + ...weekDetails, + holidayInstanceGroupId: e.target.value === '' ? null : Number(e.target.value) + }); + }; + + const isWeekUnchanged = React.useMemo(() => { + return weekDetails.name === props.week.name && + weekDetails.note === props.week.note && + weekDetails.sunday === props.week.sunday && + weekDetails.monday === props.week.monday && + weekDetails.tuesday === props.week.tuesday && + weekDetails.wednesday === props.week.wednesday && + weekDetails.thursday === props.week.thursday && + weekDetails.friday === props.week.friday && + weekDetails.saturday === props.week.saturday && + (weekDetails.holidayInstanceGroupId ?? null) === + (props.week.holidayInstanceGroupId ?? null); + }, [weekDetails, props.week]); + + // Function to handle form submission. Validates the week details and submits them to the API + const handleSubmit = () => { + props.handleClose(); + editWeekMutation(weekDetails).unwrap() + .then(() => { + showSuccessNotification(translate('week.edit.success')); + }) + .catch(error => { + showErrorNotification(translate('week.edit.failure') + error); + }); + }; + + // State to hold validation message for week name + // This is used to show an error message if the week name is invalid + const [nameValidationMessageId, setNameValidationMessageId] = React.useState(null); + + // Validate the week name to ensure it is not empty and does not already exist + const isWeekNameValid = React.useMemo(() => { + if (weekDetails.name === '') { + setNameValidationMessageId('error.required'); + return false; + } + if (weeks?.some(week => week.name.toLowerCase() === weekDetails.name.toLowerCase() && week.id !== weekDetails.id)) { + setNameValidationMessageId('week.validation.name.exists'); + return false; + } + + setNameValidationMessageId(null); + return true; + }, [weekDetails.name, weeks, weekDetails.id]); + + + // Delete confirmation dialog visibility + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [deleteWeekMutation, { isLoading: isDeleting }] = weeksApi.useDeleteWeekMutation(); + + // Function to delete the week + const handleDelete = () => { + setShowDeleteModal(false); + deleteWeekMutation({ id: weekDetails.id }).unwrap() + .then(() => { + showSuccessNotification(translate('week.delete.success')); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(translate('week.delete.failure') + error); + }); + }; + + return ( + <> + + + + +
+ +
+
+ + + {/* Name */} + + + + + + + {nameValidationMessageId && } + + + + + {/* Note */} + + + + + + + + + {/* Optional holiday rate group */} + + + + + + + {sortedHolidayInstanceGroups.map(group => ( + + ))} + + + + + {/* Days */} + + + + + + + + + + + {(['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const).map(day => ( + + + + + ))} + +
+ + {sortedDays?.map(day => ( + + ))} + +
+ +
+
+
+ + {/* Delete week */} + + {/* Discard changes */} + + {/* Save changes */} + + + + {/* Delete confirmation modal */} + setShowDeleteModal(false)} + actionConfirmText={translate('week.delete')} + actionRejectText={translate('cancel')} /> +
+ + ); +} diff --git a/src/client/app/components/weeks/WeekViewComponent.tsx b/src/client/app/components/weeks/WeekViewComponent.tsx new file mode 100644 index 0000000000..350815eb67 --- /dev/null +++ b/src/client/app/components/weeks/WeekViewComponent.tsx @@ -0,0 +1,42 @@ +/* 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 } from 'reactstrap'; +import { WeekWithHolidayRateGroup } from '../../redux/api/weeksApi'; +import EditWeekModalComponent from './EditWeekModalComponent'; + +interface WeekViewComponentProps { + week: WeekWithHolidayRateGroup; +} + +/** + * Defines the weekly pattern info card + * @param props defined above + * @returns Single weekly pattern element + */ +export default function WeekViewComponent(props: WeekViewComponentProps): React.ReactElement { + + const { week } = props; + + const [showEditModal, setShowEditModal] = React.useState(false); + + return ( +
+
{week.name}
+
+ {/* Only show first 30 characters so card does not get too big. Should limit to one line */} + {week.note?.slice(0, 29)} +
+
+ + + setShowEditModal(false)} /> +
+
+ ); +} diff --git a/src/client/app/components/weeks/WeeksDetailComponent.tsx b/src/client/app/components/weeks/WeeksDetailComponent.tsx new file mode 100644 index 0000000000..a8d81d0008 --- /dev/null +++ b/src/client/app/components/weeks/WeeksDetailComponent.tsx @@ -0,0 +1,49 @@ +/* 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 { weeksApi } from '../../redux/api/weeksApi'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import CreateWeekModalComponent from './CreateWeekModalComponent'; +import WeekViewComponent from './WeekViewComponent'; + +/** + * Defines the weekly patterns page + * @returns Weekly patterns page element + */ +export default function WeeksDetailComponent() { + + const { data: weeks } = weeksApi.useGetWeeksQuery(); + + // Sort weeks by week name + const sortedWeeks = React.useMemo(() => { + if (!weeks) { + return []; + } + return [...weeks].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); + }, [weeks]); + + return ( +
+ +
+

+ +
+ +
+

+
+ +
+
+ {sortedWeeks?.map(week => )} +
+
+
+ ); +} diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 58d58b5e14..97becb6e7e 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -33,9 +33,16 @@ export const baseApi = createApi({ 'Preferences', 'Users', 'ConversionDetails', + 'ConversionSegments', + 'Days', + 'DaySegments', 'Units', 'Cik', - 'Readings' + 'Readings', + 'Weeks', + 'Holidays', + 'HolidayInstances', + 'HolidayInstanceGroups' ], // Initially no defined endpoints, Use rtk query's injectEndpoints endpoints: () => ({}) diff --git a/src/client/app/redux/api/conversionSegmentsApi.ts b/src/client/app/redux/api/conversionSegmentsApi.ts new file mode 100644 index 0000000000..98af0b37a6 --- /dev/null +++ b/src/client/app/redux/api/conversionSegmentsApi.ts @@ -0,0 +1,122 @@ +/* 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 { baseApi } from './baseApi'; +import { ConversionSegmentData, SplitConversionSegmentPayload } from '../../types/redux/conversionSegments'; + +/** + * This file defines the conversionSegmentsApi using RTK Query. + * It provides endpoints for fetching, adding, deleting, and editing conversion segments. + * The API is injected into the baseApi created in baseApi.ts. + */ +export const conversionSegmentsApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getConversionSegmentByConversion: builder.query({ + query: ({ sourceId, destinationId }) => ({ + url: '/api/conversionSegments/sourceDestination', + method: 'POST', + body: { sourceId, destinationId } + }), + providesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + editConversionSegment: builder.mutation({ + query: ({ segment, originalStartTime, originalEndTime }) => ({ + url: '/api/conversionSegments/edit', + method: 'POST', + body: { + ...segment, + originalStartTime, + originalEndTime + } + }), + invalidatesTags: (result, error, { segment }) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + addConversionSegment: builder.mutation({ + query: segment => ({ + url: '/api/conversionSegments/addConversionSegment', + method: 'POST', + body: segment + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + splitConversionSegmentEarlier: builder.mutation({ + query: payload => ({ + url: '/api/conversionSegments/splitEarlier', + method: 'POST', + body: payload + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + + splitConversionSegmentLater: builder.mutation({ + query: payload => ({ + url: '/api/conversionSegments/splitLater', + method: 'POST', + body: payload + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + + deleteConversionSegment: builder.mutation({ + query: payload => ({ + url: '/api/conversionSegments/delete', + method: 'POST', + body: payload + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + deleteConversionSegmentEarlier: builder.mutation({ + query: payload => ({ + url: '/api/conversionSegments/deleteEarlier', + method: 'POST', + body: payload + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }), + deleteConversionSegmentLater: builder.mutation({ + query: payload => ({ + url: '/api/conversionSegments/deleteLater', + method: 'POST', + body: payload + }), + invalidatesTags: (result, error, segment) => [ + { type: 'ConversionSegments', id: 'LIST' }, + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` } + ] + }) + + }) +}); + +export const { + useGetConversionSegmentByConversionQuery, + useEditConversionSegmentMutation, + useAddConversionSegmentMutation, + useSplitConversionSegmentEarlierMutation, + useSplitConversionSegmentLaterMutation, + useDeleteConversionSegmentMutation, + useDeleteConversionSegmentEarlierMutation, + useDeleteConversionSegmentLaterMutation +} = conversionSegmentsApi; diff --git a/src/client/app/redux/api/conversionsApi.ts b/src/client/app/redux/api/conversionsApi.ts index 54f54614b9..1add9d0514 100644 --- a/src/client/app/redux/api/conversionsApi.ts +++ b/src/client/app/redux/api/conversionsApi.ts @@ -19,7 +19,16 @@ export const conversionsApi = baseApi.injectEndpoints({ query: () => 'api/ciks', providesTags: ['Cik'] }), - addConversion: builder.mutation({ + addConversion: builder.mutation({ query: conversion => ({ url: 'api/conversions/addConversion', method: 'POST', diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts new file mode 100644 index 0000000000..1b42b9ff44 --- /dev/null +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -0,0 +1,88 @@ +/* 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 { DaySegment, SplitDaySegmentPayload, UpdateDaySegmentPayload } from '../../types/redux/days'; +import { baseApi } from './baseApi'; + +export const daySegmentsApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getDaySegmentsById: builder.query({ + query: id => `api/daySegments/${id}`, + providesTags: (result, error, id) => [{ type: 'DaySegments', id }] + }), + getDaySegmentsByDayId: builder.query({ + query: dayId => ({ + url: 'api/daySegments/dayId', + method: 'POST', + body: { dayId } + }), + providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] + }), + addDaySegment: builder.mutation>({ + query: daySegment => ({ + url: 'api/daySegments/addDaySegment', + method: 'POST', + body: daySegment + }), + transformErrorResponse: res => res.data, + invalidatesTags: ['DaySegments'] + }), + splitEarlier: builder.mutation>({ + query: ({ id, newSlope, newIntercept, newNote, splitTime }) => ({ + url: 'api/daySegments/splitEarlier', + method: 'POST', + body: { id, newSlope, newIntercept, newNote, splitTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }), + splitLater: builder.mutation>({ + query: ({ id, newSlope, newIntercept, newNote, splitTime }) => ({ + url: 'api/daySegments/splitLater', + method: 'POST', + body: { id, newSlope, newIntercept, newNote, splitTime } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }), + editDaySegment: builder.mutation({ + query: daySegment => ({ + url: 'api/daySegments/edit', + method: 'POST', + body: daySegment + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }), + deleteDaySegment: builder.mutation({ + query: ({ dayId, startHour, endHour }) => ({ + url: 'api/daySegments/delete', + method: 'POST', + body: { dayId, startHour, endHour } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }), + // Deletes the provided day segment and updates the end hour of the previous segment + deleteDaySegmentEarlier: builder.mutation({ + query: ({ dayId, startHour, endHour }) => ({ + url: 'api/daySegments/deleteEarlier', + method: 'POST', + body: { dayId, startHour, endHour } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }), + // Deletes the provided day segment and updates the start hour of the next segment + deleteDaySegmentLater: builder.mutation({ + query: ({ dayId, startHour, endHour }) => ({ + url: 'api/daySegments/deleteLater', + method: 'POST', + body: { dayId, startHour, endHour } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', dayId: arg.dayId }] + }) + }) +}); diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts new file mode 100644 index 0000000000..572b56fadc --- /dev/null +++ b/src/client/app/redux/api/daysApi.ts @@ -0,0 +1,66 @@ +/* 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 { CreateDayPayload, Day } from '../../types/redux/days'; +import { baseApi } from './baseApi'; + +export const daysApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getDays: builder.query({ + query: () => 'api/days', + providesTags: result => + result + ? [...result.map(({ id }) => ({ type: 'Days' as const, id })), { type: 'Days', id: 'LIST' }] + : [{ type: 'Days', id: 'LIST' }] + }), + getDayById: builder.query({ + query: id => `api/days/${id}`, + providesTags: (result, error, id) => [{ type: 'Days', id }] + }), + addDay: builder.mutation({ + query: Day => ({ + url: 'api/days/addDay', + method: 'POST', + body: Day + }), + transformErrorResponse: res => res.data, + invalidatesTags: ['Days'] + }), + editDay: builder.mutation({ + query: Day => ({ + url: 'api/days/edit', + method: 'POST', + body: Day + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'Days', id: arg.id }] + }), + deleteDay: builder.mutation>({ + query: ({ id }) => ({ + url: 'api/days/delete', + method: 'POST', + body: { id } + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'Days', id: arg.id }] + }) + }) +}); + +export const selectDaysQueryState = daysApi.endpoints.getDays.select(); +export const selectAllDays = createSelector( + selectDaysQueryState, + ({ data: days = [] }) => days +); + +export const stableEmptyDays: Day[] = []; + +export const { + useGetDaysQuery, + useGetDayByIdQuery, + useAddDayMutation, + useEditDayMutation, + useDeleteDayMutation +} = daysApi; diff --git a/src/client/app/redux/api/holidayInstanceGroupsApi.ts b/src/client/app/redux/api/holidayInstanceGroupsApi.ts new file mode 100644 index 0000000000..dfcae74c3d --- /dev/null +++ b/src/client/app/redux/api/holidayInstanceGroupsApi.ts @@ -0,0 +1,241 @@ +/* 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 { + CreateHolidayInstanceGroupPayload, + HolidayGroupMember, + HolidayInstanceGroup, + UpdateHolidayInstanceGroupPayload +} from '../../types/redux/holidays'; +import { baseApi } from './baseApi'; + +interface HolidayInstanceGroupResponse { + id: number; + name: string; + note?: string | null; +} + +interface HolidayGroupMemberPayload { + holidayInstanceGroupId: number; + holidayInstanceId: number; +} + +const groupsUrl = 'api/holidayInstanceGroups'; +const membersUrl = 'api/holidayGroupMembers'; + +/* + * The backend stores group metadata and group membership in separate tables. + * These endpoints present the page with one combined HolidayInstanceGroup and + * coordinate both route families for every mutation. + */ +export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + // The list of groups is joined with the list of members to produce a single + // list of HolidayInstanceGroup objects. The queryFn is used to coordinate + // the two queries and return a single result. + getHolidayInstanceGroups: builder.query({ + async queryFn(_arg, _queryApi, _extraOptions, baseQuery) { + const groupsResult = await baseQuery(groupsUrl); + if (groupsResult.error) { + return { error: groupsResult.error }; + } + + const groups = groupsResult.data as HolidayInstanceGroupResponse[]; + const memberResults = await Promise.all(groups.map(group => + baseQuery(`${membersUrl}/group/${group.id}`) + )); + + const failedResult = memberResults.find(result => result.error); + if (failedResult?.error) { + return { error: failedResult.error }; + } + + return { + data: groups.map((group, index) => ({ + id: group.id, + name: group.name, + note: group.note ?? '', + holidayInstanceIds: (memberResults[index].data as HolidayGroupMember[]) + .map(member => member.holidayInstanceId) + })) + }; + }, + providesTags: result => result + ? [ + ...result.map(({ id }) => ({ type: 'HolidayInstanceGroups' as const, id })), + { type: 'HolidayInstanceGroups', id: 'LIST' } + ] + : [{ type: 'HolidayInstanceGroups', id: 'LIST' }] + }), + + // Adds a new holiday instance group and its members. + addHolidayInstanceGroup: builder.mutation({ + async queryFn(group, _queryApi, _extraOptions, baseQuery) { + const groupResult = await baseQuery({ + url: `${groupsUrl}/addHolidayInstanceGroup`, + method: 'POST', + body: { name: group.name, note: group.note } + }); + if (groupResult.error) { + return { error: groupResult.error }; + } + + const createdGroup = groupResult.data as HolidayInstanceGroupResponse; + for (const holidayInstanceId of group.holidayInstanceIds) { + const memberResult = await baseQuery({ + url: `${membersUrl}/add`, + method: 'POST', + body: { + holidayInstanceGroupId: createdGroup.id, + holidayInstanceId + } as HolidayGroupMemberPayload + }); + if (memberResult.error) { + // Best-effort rollback prevents a partially-created group. + await baseQuery({ + url: `${membersUrl}/deleteByGroup`, + method: 'POST', + body: { holidayInstanceGroupId: createdGroup.id } + }); + await baseQuery({ + url: `${groupsUrl}/delete`, + method: 'POST', + body: { id: createdGroup.id } + }); + return { error: memberResult.error }; + } + } + + return { + data: { + id: createdGroup.id, + name: createdGroup.name, + note: createdGroup.note ?? '', + holidayInstanceIds: group.holidayInstanceIds + } + }; + }, + invalidatesTags: [{ type: 'HolidayInstanceGroups', id: 'LIST' }] + }), + + // Updates a holiday instance group and its members. + editHolidayInstanceGroup: builder.mutation({ + async queryFn(group, _queryApi, _extraOptions, baseQuery) { + const currentMembersResult = await baseQuery(`${membersUrl}/group/${group.id}`); + if (currentMembersResult.error) { + return { error: currentMembersResult.error }; + } + + const editResult = await baseQuery({ + url: `${groupsUrl}/edit`, + method: 'POST', + body: { id: group.id, name: group.name, note: group.note } + }); + if (editResult.error) { + return { error: editResult.error }; + } + + const currentIds = new Set( + (currentMembersResult.data as HolidayGroupMember[]).map(member => member.holidayInstanceId) + ); + const requestedIds = new Set(group.holidayInstanceIds); + const changes: Array<{ url: string; body: HolidayGroupMemberPayload }> = []; + + currentIds.forEach(holidayInstanceId => { + if (!requestedIds.has(holidayInstanceId)) { + changes.push({ + url: `${membersUrl}/delete`, + body: { holidayInstanceGroupId: group.id, holidayInstanceId } + }); + } + }); + requestedIds.forEach(holidayInstanceId => { + if (!currentIds.has(holidayInstanceId)) { + changes.push({ + url: `${membersUrl}/add`, + body: { holidayInstanceGroupId: group.id, holidayInstanceId } + }); + } + }); + + for (const change of changes) { + const changeResult = await baseQuery({ + url: change.url, + method: 'POST', + body: change.body + }); + if (changeResult.error) { + return { error: changeResult.error }; + } + } + + return { data: null }; + }, + invalidatesTags: (_result, _error, arg) => [ + { type: 'HolidayInstanceGroups', id: arg.id }, + { type: 'HolidayInstanceGroups', id: 'LIST' } + ] + }), + + // Deletes a holiday instance group and its members. If the group cannot be deleted, its members are restored. + //TODO: add safety checks on deletes since once they are applied to a pattern you cannot delete + deleteHolidayInstanceGroup: builder.mutation>({ + async queryFn({ id }, _queryApi, _extraOptions, baseQuery) { + const currentMembersResult = await baseQuery(`${membersUrl}/group/${id}`); + if (currentMembersResult.error) { + return { error: currentMembersResult.error }; + } + const currentMemberIds = (currentMembersResult.data as HolidayGroupMember[]) + .map(member => member.holidayInstanceId); + + const deleteMembersResult = await baseQuery({ + url: `${membersUrl}/deleteByGroup`, + method: 'POST', + body: { holidayInstanceGroupId: id } + }); + if (deleteMembersResult.error) { + return { error: deleteMembersResult.error }; + } + + const deleteGroupResult = await baseQuery({ + url: `${groupsUrl}/delete`, + method: 'POST', + body: { id } + }); + if (deleteGroupResult.error) { + // The group may still be referenced by another table. Restore its + // members when the database refuses to delete the group itself. + for (const holidayInstanceId of currentMemberIds) { + await baseQuery({ + url: `${membersUrl}/add`, + method: 'POST', + body: { holidayInstanceGroupId: id, holidayInstanceId } + }); + } + return { error: deleteGroupResult.error }; + } + + return { data: null }; + }, + invalidatesTags: [{ type: 'HolidayInstanceGroups', id: 'LIST' }] + }) + }) +}); + +export const selectHolidayInstanceGroupsQueryState = + holidayInstanceGroupsApi.endpoints.getHolidayInstanceGroups.select(); +export const selectAllHolidayInstanceGroups = createSelector( + selectHolidayInstanceGroupsQueryState, + ({ data: holidayInstanceGroups = [] }) => holidayInstanceGroups +); + +export const stableEmptyHolidayInstanceGroups: HolidayInstanceGroup[] = []; + +export const { + useGetHolidayInstanceGroupsQuery, + useAddHolidayInstanceGroupMutation, + useEditHolidayInstanceGroupMutation, + useDeleteHolidayInstanceGroupMutation +} = holidayInstanceGroupsApi; diff --git a/src/client/app/redux/api/holidayInstancesApi.ts b/src/client/app/redux/api/holidayInstancesApi.ts new file mode 100644 index 0000000000..1a05f65d9f --- /dev/null +++ b/src/client/app/redux/api/holidayInstancesApi.ts @@ -0,0 +1,80 @@ +/* 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 { CreateHolidayInstancePayload, HolidayInstance, HolidayInstanceDetails } from '../../types/redux/holidays'; +import { baseApi } from './baseApi'; + +/* + * Endpoints for the `holiday_instance` table ("Holiday Rates" in the UI). + * All mutations are POST per the server's route conventions; the edit route + * requires holidayId even when it is unchanged. Mutations invalidate the + * HolidayInstances tags so the list refetches from the server. + * TODO: before delete, check group membership in Redux state and block with + * a friendly message instead of surfacing the foreign-key database error. + */ +export const holidayInstancesApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getHolidayInstances: builder.query({ + query: () => 'api/holidayInstances', + providesTags: result => + result + ? [...result.map(({ id }) => ({ type: 'HolidayInstances' as const, id })), { type: 'HolidayInstances', id: 'LIST' }] + : [{ type: 'HolidayInstances', id: 'LIST' }] + }), + // Joined with holiday + day pattern names; useful for the instance cards. + getHolidayInstancesWithDetails: builder.query({ + query: () => 'api/holidayInstances/withDetails', + providesTags: result => + result + ? [...result.map(({ id }) => ({ type: 'HolidayInstances' as const, id })), { type: 'HolidayInstances', id: 'LIST' }] + : [{ type: 'HolidayInstances', id: 'LIST' }] + }), + addHolidayInstance: builder.mutation({ + query: holidayInstance => ({ + url: 'api/holidayInstances/addHolidayInstance', + method: 'POST', + body: holidayInstance + }), + transformErrorResponse: res => res.data, + // Invalidate the list so the cards refetch from the server — global + // state is never written from the local copy. + invalidatesTags: [{ type: 'HolidayInstances', id: 'LIST' }] + }), + editHolidayInstance: builder.mutation({ + query: holidayInstance => ({ + url: 'api/holidayInstances/edit', + method: 'POST', + body: holidayInstance + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, arg) => [{ type: 'HolidayInstances', id: arg.id }, { type: 'HolidayInstances', id: 'LIST' }] + }), + deleteHolidayInstance: builder.mutation>({ + query: ({ id }) => ({ + url: 'api/holidayInstances/delete', + method: 'POST', + body: { id } + }), + transformErrorResponse: res => res.data, + invalidatesTags: [{ type: 'HolidayInstances', id: 'LIST' }] + }) + }) +}); + +export const selectHolidayInstancesQueryState = holidayInstancesApi.endpoints.getHolidayInstances.select(); +export const selectAllHolidayInstances = createSelector( + selectHolidayInstancesQueryState, + ({ data: holidayInstances = [] }) => holidayInstances +); + +export const stableEmptyHolidayInstances: HolidayInstance[] = []; + +export const { + useGetHolidayInstancesQuery, + useGetHolidayInstancesWithDetailsQuery, + useAddHolidayInstanceMutation, + useEditHolidayInstanceMutation, + useDeleteHolidayInstanceMutation +} = holidayInstancesApi; diff --git a/src/client/app/redux/api/holidaysApi.ts b/src/client/app/redux/api/holidaysApi.ts new file mode 100644 index 0000000000..1eb845ecae --- /dev/null +++ b/src/client/app/redux/api/holidaysApi.ts @@ -0,0 +1,79 @@ +/* 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 { + Holiday, + HolidayLocations, + HolidayLocationsQuery, + RefreshHolidaysRequest, + RefreshHolidaysResponse +} from '../../types/redux/holidays'; +import { baseApi } from './baseApi'; + +/* + * Endpoints for reading saved holidays and importing holidays by location. + * imported holidays are stored in the db before the list is refreshed. + */ +export const holidaysApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getHolidays: builder.query({ + query: () => 'api/holidays', + providesTags: result => + result + ? [...result.map(({ id }) => ({ type: 'Holidays' as const, id })), { type: 'Holidays', id: 'LIST' }] + : [{ type: 'Holidays', id: 'LIST' }] + }), + getHolidayById: builder.query({ + query: id => `api/holidays/${id}`, + providesTags: (result, error, id) => [{ type: 'Holidays', id }] + }), + getHolidayLocations: builder.query({ + query: locationQuery => { + const params = new URLSearchParams(); + + if (locationQuery.country) { + params.set('country', locationQuery.country); + } + + if (locationQuery.state) { + params.set('state', locationQuery.state); + } + + const queryString = params.toString(); + let url = 'api/holidays/locations'; + + if (queryString.length > 0) { + url += `?${queryString}`; + } + + return url; + } + }), + refreshHolidays: builder.mutation({ + query: body => ({ + url: 'api/holidays/refresh', + method: 'POST', + body + }), + transformErrorResponse: response => response.data, + invalidatesTags: [{ type: 'Holidays', id: 'LIST' }] + }) + }) +}); + +export const selectHolidaysQueryState = holidaysApi.endpoints.getHolidays.select(); +export const selectAllHolidays = createSelector( + selectHolidaysQueryState, + ({ data: holidays = [] }) => holidays +); + +export const stableEmptyHolidays: Holiday[] = []; + +export const { + useGetHolidaysQuery, + useGetHolidayByIdQuery, + useGetHolidayLocationsQuery, + useRefreshHolidaysMutation +} = holidaysApi; \ No newline at end of file diff --git a/src/client/app/redux/api/weeksApi.ts b/src/client/app/redux/api/weeksApi.ts new file mode 100644 index 0000000000..d5fb20a316 --- /dev/null +++ b/src/client/app/redux/api/weeksApi.ts @@ -0,0 +1,61 @@ +/* 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 { Week } from '../../types/redux/weeks'; +import { baseApi } from './baseApi'; + +export type WeekWithHolidayRateGroup = Week & { + holidayInstanceGroupId?: number | null; +}; + +/** + * This file defines the weeksApi using RTK Query. + * It provides endpoints for fetching, adding, deleting, and editing weekly patterns. + * The API is injected into the baseApi created in baseApi.ts. + */ +export const weeksApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getWeeks: builder.query({ + query: () => 'api/weeks', + // Provides a list of 'Weeks' by id. + // If any mutation invalidates any of these tags, the query will refetch. + // The 'LIST' tag is used to invalidate the entire list. + providesTags: result => + result ? + [...result.map(({ id }) => ({ type: 'Weeks', id }) as const), { type: 'Weeks', id: 'LIST' }] : + [{ type: 'Weeks', id: 'LIST' }] + }), + + addWeek: builder.mutation>({ + query: week => ({ + url: 'api/weeks/addWeek', + method: 'POST', + body: week + }), + transformErrorResponse: res => res.data, + // On successful addition, invalidates the 'Weeks' list to refetch it. + invalidatesTags: (result, error) => !error ? [{ type: 'Weeks', id: 'LIST' }] : [] + }), + + deleteWeek: builder.mutation>({ + query: weekId => ({ + url: 'api/weeks/delete', + method: 'POST', + body: weekId + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, weekId) => [{ type: 'Weeks', id: weekId.id }] + }), + + editWeek: builder.mutation({ + query: week => ({ + url: 'api/weeks/edit', + method: 'POST', + body: week + }), + transformErrorResponse: res => res.data, + invalidatesTags: (result, error, week) => [{ type: 'Weeks', id: week.id }] + }) + }) +}); diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index a17436adb7..442168711d 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -1,25 +1,28 @@ +/* eslint-disable @typescript-eslint/indent */ /* 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 * 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 { selectAdminPreferences } from '../../redux/slices/adminSlice'; -import { ConversionData } from '../../types/redux/conversions'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; +import { DaySegment, SplitDaySegmentPayload } from '../../types/redux/days'; +import { ConversionSegmentData, SplitConversionSegmentPayload } from '../../types/redux/conversionSegments'; import { MeterData, MeterTimeSortType } from '../../types/redux/meters'; -import { UnitData, UnitType } from '../../types/redux/units'; +import { DisableChecksType, UnitData, UnitType } from '../../types/redux/units'; +import { Week } from '../../types/redux/weeks'; import { unitsCompatibleWithUnit } from '../../utils/determineCompatibleUnits'; import { AreaUnitType } from '../../utils/getAreaUnitConversion'; -import { noUnitTranslated, potentialGraphicUnits } from '../../utils/input'; +import { MAX_VAL, MIN_VAL, noUnitTranslated, potentialGraphicUnits } from '../../utils/input'; import translate from '../../utils/translate'; +import { selectAllDays } from '../api/daysApi'; import { selectAllUnits, selectUnitDataById } from '../api/unitsApi'; import { selectVisibleMetersAndGroups } from './authVisibilitySelectors'; import { createAppSelector } from './selectors'; -import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; -import { DisableChecksType } from '../../types/redux/units'; -import { MAX_VAL, MIN_VAL } from '../../utils/input'; export const MIN_DATE_MOMENT = moment(0).utc(); export const MAX_DATE_MOMENT = moment(0).utc().add(5000, 'years'); @@ -207,11 +210,9 @@ export const selectIsValidConversion = createAppSelector( [ selectUnitDataById, selectConversionsDetails, - (_state, conversionDetails: ConversionData) => conversionDetails.sourceId, - (_state, conversionDetails: ConversionData) => conversionDetails.destinationId, - (_state, conversionDetails: ConversionData) => conversionDetails.bidirectional + (_state, conversionState) => conversionState ], - (unitDataById, conversions, sourceId, destinationId, bidirectional): [boolean, string] => { + (unitDataById, conversions, conversionState): [boolean, string] => { /* Create Conversion Validation: Source equals destination: invalid conversion Conversion exists: invalid conversion @@ -222,6 +223,10 @@ export const selectIsValidConversion = createAppSelector( Cannot mix unit represent TODO Some of these can go away when we make the menus dynamic. */ + const sourceId = conversionState.overallConversion.sourceId; + const destinationId = conversionState.overallConversion.destinationId; + const bidirectional = conversionState.overallConversion.bidirectional; + // The destination cannot be a meter unit. if (destinationId !== -999 && unitDataById[destinationId].typeOfUnit === UnitType.meter) { return [false, translate('conversion.create.destination.meter')]; @@ -331,14 +336,108 @@ export const selectDefaultCreateConversionValues = createAppSelector( destinationId: -999, destinationOptions: sortedUnitData.filter(unit => unit.typeOfUnit !== 'meter'), bidirectional: true, + overallConversionNote: '', + initialConversionNote: '', slope: 0, intercept: 0, - note: '' + weeklyPattern: -99 }; return defaultValues; } ); +export const selectDefaultCreateDayValues = createAppSelector( + [selectAllUnits], + () => { + const defaultValues = { + name: '', + DayNote: '', + slope: 0, + intercept: 0, + startHour: 0, + endHour: 24, + initialSegmentNote: '' + }; + return defaultValues; + } +); + +export const selectDefaultSplitConversionSegmentValues = createSelector( + [(segment: ConversionSegmentData) => segment], + (segment): SplitConversionSegmentPayload => ({ + splitTime: '', + sourceId: segment.sourceId, + destinationId: segment.destinationId, + startTime: segment.startTime, + endTime: segment.endTime, + newSlope: 0, + newIntercept: 0, + newWeekPatternsId: -99, + newNote: '' + }) +); + + +const selectDaySegmentId = (daySegment: DaySegment) => daySegment.id; +export const selectDefaultSplitDaySegmentValues = createSelector( + [selectDaySegmentId], + id => ({ + id, + newSlope: 0, + newIntercept: 0, + newNote: '', + splitTime: -999 + } as SplitDaySegmentPayload) +); + +export const selectDefaultCreateWeekValues = createAppSelector<[], Omit>( + [], + () => ({ + name: '', + note: '', + sunday: -999, + monday: -999, + tuesday: -999, + wednesday: -999, + thursday: -999, + friday: -999, + saturday: -999, + holidayInstanceGroupId: -99 + }) +); + +/** + * Validates the creation of a new daily pattern. + * - Ensures the day name is not blank. + * - Ensures the day name does not already exist in the list of days. + * Returns a tuple: [isValid, message]. + * @param _state The Redux state (unused in this selector). + * @param patternState The full pattern state object. + * @param patternState.Day The Day object containing the name of the daily pattern. + * @param patternState.Day.name The name of the daily pattern to validate. + * @returns A tuple where the first element is a boolean indicating validity, and the second is a message string. + */ +export const selectIsValidCreateDay = createAppSelector( + [ + selectAllDays, + (_state, patternState: { Day: { name: string } }) => patternState + ], + (days, patternState): [boolean, string] => { + const name = patternState.Day?.name; + if (!name || name === '') { + return [false, translate('day.create.name.required')]; + } + // Check if name already exists + const exists = days.some(day => + day.name === name + ); + if (exists) { + return [false, translate('day.create.name.exists')]; + } + return [true, 'Daily Pattern is Valid']; + } +); + /* Create Meter Validation: Name cannot be blank Area must be positive or zero @@ -388,4 +487,3 @@ export const isValidCreateMeter = createAppSelector( } ); - diff --git a/src/client/app/redux/thunks/exportThunk.ts b/src/client/app/redux/thunks/exportThunk.ts index 06eb6cc752..305ca56887 100644 --- a/src/client/app/redux/thunks/exportThunk.ts +++ b/src/client/app/redux/thunks/exportThunk.ts @@ -183,30 +183,16 @@ export const exportRawReadings = createAppThunk( let unitIdentifier; // A complication is that a unit associated with a meter is not the one the user // sees when graphing. Now try to find the graphing unit. - // Try to find expected conversion from meter with slope = 1 and intercept = 0 + // Find the first conversion for this meter unit const conversion = find(conversionState, function (c: ConversionData) { - return c.sourceId === meterUnitId && c.slope === 1 && c.intercept === 0; + return c.sourceId === meterUnitId; }); if (!conversion) { - // This is the unusual case where the conversion is not 1, 0. - // We find the first conversion and use it. - const anyConversion = find(conversionState, function (c: ConversionData) { - // Conversion has source that is the meter unit. - return c.sourceId === meterUnitId; - }); - if (!anyConversion) { - // Could not find a conversion with this meter. This should never happen. - // Use the identifier of currentMeter unit and extra info. - unitIdentifier = unitsDataById[meterUnitId].identifier + - ' (this is the meter unit which is unusual)'; - // Nice if logged warning but no easy way so don't. - } else { - // Use this conversion but give slope/destination since changes values. - unitIdentifier = unitsDataById[anyConversion.destinationId].identifier + - ` (but conversion from meter values of slope = ${anyConversion.slope} and intercept = ${anyConversion.intercept}`; - } + // Could not find a conversion with this meter. This should never happen. + unitIdentifier = unitsDataById[meterUnitId].identifier + + ' (this is the meter unit which is unusual)'; } else { - // This is the typical case where there was a conversion from the meter of 1, 0. + // Use the destination unit identifier for the conversion unitIdentifier = unitsDataById[conversion.destinationId].identifier; } diff --git a/src/client/app/styles/modalStyle.tsx b/src/client/app/styles/modalStyle.tsx index 71d6ea4e45..a7417b2e62 100644 --- a/src/client/app/styles/modalStyle.tsx +++ b/src/client/app/styles/modalStyle.tsx @@ -43,6 +43,13 @@ export const largeTitleStyle: React.CSSProperties = { display: 'inline-block' }; +export const modalSectionDividerStyle: React.CSSProperties = { + borderTop: '1px solid gray', + left: '-2rem', + width: '100vw', + position: 'relative' +}; + export const pill: React.CSSProperties = { margin: '2px', userSelect: 'none', diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 60490fb536..ed5287d54e 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -70,35 +70,75 @@ const LocaleTranslationData = { "conversion.create.destination.meter": "The destination cannot be a meter", "conversion.create.exists": "This conversion already exists", "conversion.create.exists.inverse": "This conversion already exists where one is not bidirectional", + "conversion.create.failure": "Failed to create a conversion with error: ", "conversion.create.mixed.represent": "A conversion cannot mix units of quantity, flow and raw", "conversion.create.source.destination.not": "Source or destination not set", "conversion.create.source.destination.same": "The source and destination cannot be the same for a conversion", + "conversion.create.success": "Successfully created a conversion.", "conversion.delete.conversion": "Delete Conversion", "conversion.delete.meter.orphan": "Deleting this meter conversion will orphan (make ungraphable) meter unit", "conversion.delete.meter.related": "The following meters use meter unit", "conversion.delete.meter.reduce.graphable": "Deleting this meter conversion will reduce the number of graphable units for meters using meter unit", "conversion.delete.restricted": "This conversion cannot be deleted until the significant consequences of doing this are addressed.", "conversion.delete.suffix.disable": "Deleting this suffix conversion will disable use of suffix", - "conversion.delete.unit" : "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.", + "conversion.delete.segment.earlier": "Are you sure you want to delete the segment from {start} to {end} and update the previous one?", + "conversion.delete.segment.later": "Are you sure you want to delete the segment from {start} to {end} and update the next one?", + "conversion.delete.unit": "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.", "conversion.delete.unit.orphan": "Deleting this unit conversion will orphan unit", "conversion.destination": "Destination:", "conversion.dropdown.displayable.option.admin": "Admin", - "conversion.edit.conversion": "Edit Conversion", + "conversion.edit.conversion": "Details/Edit Conversion", + "conversion.edit.segment": "Edit Conversion Segment", + "conversion.error.datetime.invalid": "Invalid format. Use YYYY-MM-DD HH:mm:ss", + "conversion.error.segment.splitTime": "Split time must be strictly between the segment start and end.", + "conversion.error.split.beforeStart": "The split time must come after this segment's start time", + "conversion.error.split.afterEnd": "The split time must come before this segment's end time", + "conversion.error.split.time.invalid": "Invalid split time. Please use format YYYY-MM-DD or YYYY-MM-DD HH:mm:ss.", "conversion.failed.to.create.conversion": "Failed to create a conversion.", "conversion.failed.to.delete.conversion": "Failed to delete conversion", "conversion.failed.to.edit.conversion": "Failed to edit conversion.", - "conversion.intercept": "Intercept:", "conversion.is.deleted": "Conversion removed from database", + "conversion.note": "Note", + "conversion.overall": "Overall Conversion", + "conversion.overall.delete": "Delete Overall Conversion", + "conversion.overall.discard": "Discard Overall Changes", + "conversion.overall.save": "Save Overall Conversion", + "conversion.overall.save.error": "Failed to update conversion.", + "conversion.overall.save.success": "Conversion updated successfully.", + "conversion.overall.subtitle": "This section defines the overall conversion between units.", + "conversion.pattern": "Pattern", + "conversion.pattern.no": "No Pattern", "conversion.represent": "Conversion Represent:", + "conversion.segment.delete.error": "An error occurred while deleting the segment.", + "conversion.segment.delete.success": "Successfully deleted conversion segment.", + "conversion.segment.split.error": "An error occurred while splitting the segment.", + "conversion.segment.split.success": "Successfully split conversion segment.", + "conversion.segment.save.error": "An error occurred while saving the segment.", + "conversion.segment.save.success": "Segment saved successfully.", + "conversion.segment.warning.invalidStartRange": "Start time must be after the previous segment's end and before this segment's end", + "conversion.segment.warning.invalidEndRange": "End time must be before the next segment's start and after this segment's start", + "conversion.segment.warning.previousAdjusted": "The previous segment's end time was adjusted to match the new start time", + "conversion.segment.warning.previousAdjustFailed": "Failed to adjust the previous segment.", + "conversion.segment.warning.nextAdjusted": "The next segment's start time was adjusted to match the new end time", + "conversion.segment.warning.nextAdjustFailed": "Failed to adjust the next segment.", + "conversion.segments.table": "Conversion Segments Table", + "conversion.segments.table.subtitle": "This section shows how the conversion behaves over specific time periods.", "conversion.select.destination": "Select a destination unit", "conversion.select.source": "Select a source unit", - "conversion.slope": "Slope:", "conversion.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this conversion?", "conversion.source": "Source:", + "conversion.split.datetime.placeholder": "YYYY-MM-DD HH:mm:ss (complete)", + "conversion.split.datetime.prompt": "Enter the datetime (YYYY-MM-DD HH:mm:ss) to split the segment:", "conversion.submit.new.conversion": "Submit New Conversion", "conversion.successfully.create.conversion": "Successfully created a conversion.", "conversion.successfully.delete.conversion": "Successfully deleted conversion.", "conversion.successfully.edited.conversion": "Successfully edited conversion.", + "conversion.table.delete.earlier": "Delete Earlier\u2191", + "conversion.table.delete.later": "Delete Later\u2193", + "conversion.table.split.earlier": "Split Earlier\u2191", + "conversion.table.split.later": "Split Later\u2193", + "conversion.time.end": "End time", + "conversion.time.start": "Start time", "conversions": "Conversions", "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", @@ -136,6 +176,45 @@ const LocaleTranslationData = { "custom.value": "Custom value", "date.range": 'Date Range', "day": "Day", + "day.create": "Create a Day", + "day.create.failure": "Failed to create a day with error: ", + "day.create.name.exists": "Day already exists", + "day.create.name.required": "Day name is required", + "day.create.success": "Successfully created a day.", + "day.delete.button": "Delete Overall Day", + "day.delete.confirm": "Are you sure you want to delete this day?", + "day.delete.error": "Failed to delete day.", + "day.delete.success": "Successfully deleted day.", + "day.edit": "Details/Edit Day", + "day.edit.discard": "Discard Overall Changes", + "day.edit.error": "Failed to edit day.", + "day.edit.save": "Save Overall Day", + "day.edit.success": "Successfully edited day.", + "day.end.hour": "End Hour", + "day.friday": "Friday", + "day.monday": "Monday", + "day.of.week": "Day of Week", + "day.pattern": "Day Pattern:", + "day.pattern.required": "Day pattern is required", + "day.pattern.select": "Select a day pattern", + "day.saturday": "Saturday", + "day.segments.delete.confirm.earlier": "Are you sure you want to delete the segment for hours {startHour} - {endHour} and update the previous one?", + "day.segments.delete.confirm.later": "Are you sure you want to delete the segment for hours {startHour} - {endHour} and update the next one?", + "day.segments.delete.confirm.button": "Delete Segment", + "day.segments.delete.invalid.direction": "Invalid direction for segment deletion: {direction}", + "day.segments.delete.success": "Successfully deleted day segment.", + "day.segments.edit.start.hour.invalid": "Must be an integer greater than {min} and less than the end hour", + "day.segments.edit.end.hour.invalid": "Must be an integer greater than the start hour and less than {max}", + "day.segments.edit.save": "Save Segment", + "day.segments.split.success": "Successfully split day segment.", + "day.segments.table.timeRange": "Time Range", + "day.segments.table.title": "Day Segments Table", + "day.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this segment?", + "day.start.hour": "Start Hour", + "day.sunday": "Sunday", + "day.thursday": "Thursday", + "day.tuesday": "Tuesday", + "day.wednesday": "Wednesday", "days": "Days", "days.enter": "Enter in days and then hit enter", "decreasing": "decreasing", @@ -158,10 +237,13 @@ const LocaleTranslationData = { "default.time.zone": "Default Time Zone", "default.warning.file.size": "Default Warning File Size", "defaultGraphicUnit": "Default Graphic Unit:", + "delete.earlier": "Delete\u2191", "delete.group": "Delete Group", + "delete.later": "Delete\u2193", "delete.map": "Delete Map", "delete.self": "Cannot delete your own Account.", "delete.user": "Delete User", + "delete.week": "Delete Week", "descending": "Descending", "disable": "Disable", "disable.checks": "Disable Checks", @@ -181,6 +263,7 @@ const LocaleTranslationData = { "edit.unit": "Details/Edit Unit", "edit.user": "Edit User", "edited": "edited", + "edit.segment.title": "Edit Segment", "enable": "Enable", "error.bar": "Show error bars", "error.bounds": "Must be between {min} and {max}.", @@ -246,6 +329,11 @@ const LocaleTranslationData = { "help.admin.conversioncreate": "This page allows admins to create conversions. Please visit {link} for further details and information.", "help.admin.conversionedit": "This page allows admins to edit conversions. Please visit {link} for further details and information.", "help.admin.conversionview": "This page shows information on conversions. Please visit {link} for further details and information.", + "help.admin.daycreate": "This page allows admins to create days. Please visit {link} for further details and information.", + "help.admin.dayedit": "This page allows admins to edit days. Please visit {link} for further details and information.", + "help.admin.dayview": "This page shows information on days. Please visit {link} for further details and information.", + "help.admin.holidayview": "This page allows admins to import and view holidays. Please visit {link} for further details and information.", + "help.admin.holidayrateview": "This page shows information on holiday rates. Please visit {link} for further details and information.", "help.admin.groupcreate": "This page allows admins to create groups. Please visit {link} for further details and information.", "help.admin.groupedit": "This page allows admins to edit groups. Please visit {link} for further details and information.", "help.admin.groupview": "This page allows admins to view groups. Please visit {link} for further details and information.", @@ -259,6 +347,9 @@ const LocaleTranslationData = { "help.admin.unitedit": "This page allows admins to edit units. Please visit {link} for further details and information.", "help.admin.unitview": "This page shows information on units. Please visit {link} for further details and information.", "help.admin.users": "This page allows admins to view and edit users. Please visit {link} for further details and information.", + "help.admin.weekcreate": "This page allows admins to create weeks. Please visit {link} for further details and information.", + "help.admin.weekedit": "This page allows admins to edit weeks. Please visit {link} for further details and information.", + "help.admin.weekview": "This page allows admins to view and edit weeks. Please visit {link} for further details and information.", "help.csv.meters": "This page allows admins to upload meters via a CSV file. Please visit {link} for further details and information.", "help.csv.readings": "This page allows certain users to upload readings via a CSV file. Please visit {link} for further details and information.", "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.", @@ -291,6 +382,69 @@ const LocaleTranslationData = { "hide": "Hide", "hide.options": "Hide options", "hide.options.in.link": "Hide options in link", + "holiday.base": "Base Holiday:", + "holiday.base.changed.warning": "Base holiday changed - make sure the name still matches.", + "holiday.base.required": "Base holiday is required", + "holiday.base.select": "Select or search for a holiday...", + "holiday.base.unknown": "(unknown holiday)", + "holiday.country": "Country", + "holiday.country.select": "Select a country", + "holiday.date": "Date:", + "holiday.day.name": "Holiday Day Name:", + "holiday.history.warning": "The holiday source does not reliably identify when holidays began or ended. Review historical schedules before using these holidays for past data.", + "holiday.import.button": "Import/Update Holidays", + "holiday.import.country.required": "Select a country before importing holidays.", + "holiday.import.failure": "Failed to import holidays: ", + "holiday.import.success": "Holidays imported/updated: ", + "holiday.instance.group.create": "Create Holiday Rate Group", + "holiday.instance.group.create.failure": "Failed to create holiday rate group.", + "holiday.instance.group.create.success": "Successfully created holiday rate group.", + "holiday.instance.group.delete": "Delete Holiday Rate Group", + "holiday.instance.group.delete.confirm": "Are you sure you want to delete this holiday rate group?", + "holiday.instance.group.delete.failure": "Failed to delete holiday rate group.", + "holiday.instance.group.delete.success": "Successfully deleted holiday rate group.", + "holiday.instance.group.details": "Details/Edit Group", + "holiday.instance.group.edit": "Edit Holiday Rate Group", + "holiday.instance.group.edit.failure": "Failed to edit holiday rate group.", + "holiday.instance.group.edit.success": "Successfully edited holiday rate group.", + "holiday.instance.group.empty": "None", + "holiday.instance.groups": "Holiday Rate Groups", + "holiday.instance.group.load.failure": "Unable to load holiday rate group data from the database.", + "holiday.instance.group.loading": "Loading holiday rate groups...", + "holiday.instances": "Holiday Rates: ", + "holiday.location": "Location:", + "holiday.none": "No holidays are stored for this location and year.", + "holiday.rate.create": "Create a Holiday Rate", + "holiday.rate.create.failure": "Failed to create a holiday rate with error: ", + "holiday.rate.create.save": "Create Holiday Rate", + "holiday.rate.create.success": "Successfully created a holiday rate.", + "holiday.rate.delete": "Delete Holiday Rate", + "holiday.rate.delete.confirm": "Are you sure you want to delete this holiday rate? This cannot be undone.", + "holiday.rate.delete.failure": "Failed to delete holiday rate with error: ", + "holiday.rate.delete.success": "Successfully deleted holiday rate.", + "holiday.rate.edit": "Details/Edit Holiday Rate", + "holiday.rate.edit.failure": "Failed to edit holiday rate with error: ", + "holiday.rate.edit.success": "Successfully edited holiday rate.", + "holiday.rate.name.required": "Name is required", + "holiday.rate.none": "No holiday rates have been created yet.", + "holiday.rate.save": "Save Holiday Rate", + "holiday.rates": "Holiday Rates", + "holiday.region.unavailable": "Unavailable", + "holiday.region": "Region: ", + "holiday.region.none": "None", + "holiday.saved": "Saved Holidays", + "holiday.state": "State/Province:", + "holiday.state.select": "No state or province", + "holiday.type": "Type:", + "holiday.type.bank": "Bank", + "holiday.type.custom": "Custom", + "holiday.type.observance": "Observance", + "holiday.type.optional": "Optional", + "holiday.type.public": "Public", + "holiday.type.school": "School", + "holiday.year": "Year:", + "holiday.year.invalid": "Enter a valid year.", + "holidays": "Holidays", "home": "Home", "hour": "Hour", "identifier": "Identifier:", @@ -301,10 +455,13 @@ const LocaleTranslationData = { "increasing": "increasing", "info": " for more information. ", "initializing": "Initializing", + "initial.conversion": "Initial Conversion Segment", + "initial.segment": "Initial Segment", "input.gps.coords.first": "input GPS coordinate that corresponds to the point: ", "input.gps.coords.second": "in this format -> latitude,longitude", "input.gps.range": "Invalid GPS coordinate, latitude must be an integer between -90 and 90, longitude must be an integer between -180 and 180. You input: ", "insufficient.readings": "Insufficient readings data to process comparison for ", + "intercept": "Intercept", "invalid.input": "Invalid input", "invalid.number": "Please submit a valid number (between 0 and 2.0)", "invalid.token.login": "Token has expired. Please log in again.", @@ -418,7 +575,7 @@ const LocaleTranslationData = { "no": "no", "no.data.in.range": "No Data In Date Range", "no.logs": "No logs to display. Please select another log type or data range", - "note": "Note: ", + "note": "Note:", "num.logs.display": "Number of logs to display", "oed": "Open Energy Dashboard", "oed.description": "Open Energy Dashboard is an independent open source project. ", @@ -431,6 +588,8 @@ const LocaleTranslationData = { "page.user.refresh.directions": "If clicking the \"Return to Dashboard\" button does not work then please click the button below to restart your OED session", "password": "Password: ", "password.confirm": "Confirm password: ", + "patterns.daily": "Patterns - Daily", + "patterns.weekly": "Patterns - Weekly", "per.day": "Per Day", "per.hour": "Per Hour", "per.minute": "Per Minute", @@ -460,7 +619,9 @@ const LocaleTranslationData = { "save.role.changes": "Save role changes", "second": "Second", "select.all": "Select All", + "select.day": "Select Day", "select.groups": "Select Groups", + "select.holiday.rates": "Select Holiday Rates", "select.map": "Select Map", "select.meter": "Select Meter", "select.meter.group": "Select meter or group to graph", @@ -468,16 +629,23 @@ const LocaleTranslationData = { "select.meters": "Select Meters", "select.shift.amount": "Select shift amount", "select.unit": "Select Unit", + "segment.note": "Segment Note:", "shift.date.interval": "Shift Date Interval", "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately", "show": "Show", "show.all.logs": "Show All Logs ", + "show.all.segments": "Show All Segments", "show.grid": "Show grid", "show.options": "Show options", "show.in.pages": "Show in Pages", "site.settings": "Site Settings", "site.title": "Site Title", + "slope": "Slope", "sort": "Sort Order", + "split.earlier": "Split\u2191", + "split.later": "Split\u2193", + "split.hour.prompt": "Enter the hour to split the segment", + "split.hour.invalid": "Please enter a valid hour between {start} and {end}", "submit": "Submit", "submit.changes": "Submit changes", "submit.new.user": "Submit new user", @@ -572,6 +740,18 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics", "website": "website", "week": "Week", + "week.create": "Create a Week", + "week.create.failure": "Failed to create a week with message: ", + "week.create.success": "Successfully created a week.", + "week.delete": "Delete Week", + "week.delete.confirm": "Are you sure you want to delete this week?", + "week.delete.failure": "Failed to delete week with message: ", + "week.delete.success": "Successfully deleted week.", + "week.edit": "Edit Week", + "week.edit.failure": "Failed to edit week with message: ", + "week.edit.success": "Successfully edited week.", + "week.validation.name.exists": "A week with this name already exists.", + "weeks": "Weeks", "yes": "yes", "yesterday": "Yesterday", "you.cannot.create.a.cyclic.group": "You cannot create a cyclic group", @@ -640,35 +820,75 @@ const LocaleTranslationData = { "conversion.create.destination.meter": "The destination cannot be a meter\u{26A1}", "conversion.create.exists": "This conversion already exists\u{26A1}", "conversion.create.exists.inverse": "This conversion already exists where one is not bidirectional\u{26A1}", + "conversion.create.failure": "Failed to create a conversion with error: \u{26A1}", "conversion.create.mixed.represent": "A conversion cannot mix units of quantity, flow and raw\u{26A1}", "conversion.create.source.destination.not": "Source or destination not set\u{26A1}", "conversion.create.source.destination.same": "The source and destination cannot be the same for a conversion\u{26A1}", + "conversion.create.success": "Successfully created a conversion\u{26A1}", "conversion.delete.conversion": "Delete Conversion\u{26A1}", "conversion.delete.meter.orphan": "Deleting this meter conversion will orphan (make ungraphable) meter unit\u{26A1}", "conversion.delete.meter.related": "The following meters use meter unit\u{26A1}", "conversion.delete.meter.reduce.graphable": "Deleting this meter conversion will reduce the number of graphable units for meters using meter unit\u{26A1}", "conversion.delete.restricted": "This conversion cannot be deleted until the significant consequences of doing this are addressed.\u{26A1}", "conversion.delete.suffix.disable": "Deleting this suffix conversion will disable use of suffix\u{26A1}", - "conversion.delete.unit" : "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.\u{26A1}", + "conversion.delete.segment.earlier": "Êtes-vous sûr de vouloir supprimer le segment de {start} à {end} et mettre à jour le segment précédent?", + "conversion.delete.segment.later": "Êtes-vous sûr de vouloir supprimer le segment de {start} à {end} et mettre à jour le segment suivant?", + "conversion.delete.unit": "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.\u{26A1}", "conversion.delete.unit.orphan": "Deleting this unit conversion will orphan unit\u{26A1}", "conversion.destination": "Destination:\u{26A1}", "conversion.dropdown.displayable.option.admin": "admin\u{26A1}", "conversion.edit.conversion": "Edit Conversion\u{26A1}", + "conversion.edit.segment": "Modifier le segment de conversion", + "conversion.error.datetime.invalid": "Format invalide. Utilisez YYYY-MM-DD HH:mm:ss", + "conversion.error.segment.splitTime": "L'heure de division doit être strictement comprise entre le début et la fin du segment.", + "conversion.error.split.beforeStart": "L'heure de division doit être après l'heure de début de ce segment", + "conversion.error.split.afterEnd": "L'heure de division doit être avant l'heure de fin de ce segment", + "conversion.error.split.time.invalid": "Heure de découpage invalide. Veuillez utiliser le format AAAA-MM-JJ ou AAAA-MM-JJ HH:mm:ss.", "conversion.failed.to.create.conversion": "Failed to create a conversion.\u{26A1}", "conversion.failed.to.delete.conversion": "Failed to delete conversion\u{26A1}", "conversion.failed.to.edit.conversion": "Failed to edit conversion.\u{26A1}", - "conversion.intercept": "Intercept:\u{26A1}", "conversion.is.deleted": "Conversion removed from database\u{26A1}", + "conversion.note": "Noter", + "conversion.overall": "Conversion Globale", + "conversion.overall.delete": "Supprimer la conversion globale", + "conversion.overall.discard": "Annuler les modifications globales", + "conversion.overall.save": "Enregistrer la conversion globale", + "conversion.overall.save.error": "Échec de la mise à jour de la conversion.", + "conversion.overall.save.success": "Conversion mise à jour avec succès.", + "conversion.overall.subtitle": "Cette section définit la conversion globale entre les unités.", + "conversion.pattern": "Pattern\u{26A1}", + "conversion.pattern.no": "No Pattern\u{26A1}", "conversion.represent": "Conversion Represent:\u{26A1}", + "conversion.segment.delete.error": "Une erreur est survenue lors de la suppression du segment.", + "conversion.segment.delete.success": "Segment de conversion supprimé avec succès.", + "conversion.segment.split.error": "Une erreur est survenue lors de la scission du segment.", + "conversion.segment.split.success": "Segment de conversion scindé avec succès.", + "conversion.segment.save.error": "Une erreur est survenue lors de l'enregistrement du segment.", + "conversion.segment.save.success": "Segment enregistré avec succès.", + "conversion.segment.warning.invalidStartRange": "L'heure de début doit être postérieure à l'heure de fin du segment précédent et antérieure à l'heure de fin de ce segment", + "conversion.segment.warning.invalidEndRange": "L'heure de fin doit être antérieure à l'heure de début du segment suivant et postérieure à l'heure de début de ce segment", + "conversion.segment.warning.previousAdjusted": "L'heure de fin du segment précédent a été ajustée pour correspondre à la nouvelle heure de début", + "conversion.segment.warning.previousAdjustFailed": "Échec de l'ajustement du segment précédent.", + "conversion.segment.warning.nextAdjusted": "L'heure de début du segment suivant a été ajustée pour correspondre à la nouvelle heure de fin", + "conversion.segment.warning.nextAdjustFailed": "Échec de l'ajustement du segment suivant.", + "conversion.segments.table": "Table des segments de conversion", + "conversion.segments.table.subtitle": "Cette section montre comment la conversion fonctionne sur des périodes spécifiques.", "conversion.select.destination": "Select a destination unit\u{26A1}", "conversion.select.source": "Select a source unit\u{26A1}", - "conversion.slope": "Slope:\u{26A1}", "conversion.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this conversion?\u{26A1}", "conversion.source": "Source:\u{26A1}", + "conversion.split.datetime.placeholder": "AAAA-MM-JJ HH:mm:ss (complet)", + "conversion.split.datetime.prompt": "Entrez la date et l'heure (AAAA-MM-JJ HH:mm:ss) pour diviser le segment:", "conversion.submit.new.conversion": "Submit New Conversion\u{26A1}", "conversion.successfully.create.conversion": "Successfully created a conversion.\u{26A1}", "conversion.successfully.delete.conversion": "Successfully deleted conversion.\u{26A1}", "conversion.successfully.edited.conversion": "Successfully edited conversion.\u{26A1}", + "conversion.table.delete.earlier": "Supprimer précédent\u2191", + "conversion.table.delete.later": "Supprimer suivant\u2193", + "conversion.table.split.earlier": "Diviser avant\u2191", + "conversion.table.split.later": "Diviser après\u2193", + "conversion.time.start": "Heure de début", + "conversion.time.end": "Heure de fin", "conversions": "Conversions\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", @@ -705,8 +925,47 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", "date.range": 'Plage de dates', - "day": "Journée", - "days": "Journées", + "day": "Jour", + "day.create": "Créer un Jour", + "day.create.failure": "Échec de la création du jour avec l'erreur : ", + "day.create.name.exists": "Le jour existe déjà", + "day.create.name.required": "Le nom du jour est requis", + "day.create.success": "Jour créé avec succès.", + "day.delete.button": "Delete Overall Day\u{26A1}", + "day.delete.confirm": "Êtes-vous sûr de vouloir supprimer ce jour?", + "day.delete.error": "Échec de la suppression du jour.", + "day.delete.success": "Jour supprimé avec succès.", + "day.edit": "Détails/Modifier Jour", + "day.edit.discard": "Discard Overall Changes\u{26A1}", + "day.edit.error": "Échec de la modification du jour.", + "day.edit.save": "Save Overall Day\u{26A1}", + "day.edit.success": "Jour modifié avec succès.", + "day.end.hour": "Heure de fin", + "day.friday": "Vendredi", + "day.monday": "Lundi", + "day.of.week": "Jour de la semaine", + "day.pattern": "Day Pattern:\u{26A1}", + "day.pattern.required": "Day pattern is required\u{26A1}", + "day.pattern.select": "Select a day pattern\u{26A1}", + "day.saturday": "Samedi", + "day.segments.delete.confirm.earlier": "Êtes-vous sûr de vouloir supprimer le segment de {startHour} - {endHour} et mettre à jour le segment précédent?", + "day.segments.delete.confirm.later": "Êtes-vous sûr de vouloir supprimer le segment de {startHour} - {endHour} et mettre à jour le segment suivant?", + "day.segments.delete.confirm.button": "Supprimer le Segment", + "day.segments.delete.invalid.direction": "Invalid direction for segment deletion: {direction}\u{26A1}", + "day.segments.delete.success": "Successfully deleted day segment.\u{26A1}", + "day.segments.edit.start.hour.invalid": "Must be an integer greater than {min} and less than the end hour\u{26A1}", + "day.segments.edit.end.hour.invalid": "Must be an integer greater than the start hour and less than {max}\u{26A1}", + "day.segments.edit.save": "Enregistrer le Segment", + "day.segments.split.success": "Successfully split day segment.\u{26A1}", + "day.segments.table.timeRange": "Plage horaire", + "day.segments.table.title": "Tableau des segments de jour", + "day.slope.intercept.zero": "La pente et l'ordonnée à l'origine sont toutes deux nulles. Êtes-vous sûr de vouloir enregistrer ce segment?", + "day.start.hour": "Heure de début", + "day.sunday": "Dimanche", + "day.thursday": "Jeudi", + "day.tuesday": "Mardi", + "day.wednesday": "Mercredi", + "days": "Jours", "days.enter": "Enter in days and then hit enter\u{26A1}", "decreasing": "decreasing\u{26A1}", "default.area.normalize": "Normalize readings by area by default\u{26A1}", @@ -728,10 +987,13 @@ const LocaleTranslationData = { "default.time.zone": "Zona Horaria Predeterminada", "default.warning.file.size": "Taille du fichier d'avertissement par défaut", "defaultGraphicUnit": "Default Graphic Unit:\u{26A1}", + "delete.earlier": "Supprimer\u2191", "delete.group": "Supprimer le Groupe", + "delete.later": "Supprimer\u2193", "delete.map": "Supprimer la carte", "delete.self": "Impossible de supprimer votre propre compte.", "delete.user": "Supprimer l'utilisateur", + "delete.week": "Supprimer la semaine", "descending": "Descendant", "disable": "Désactiver", "disable.checks": "Disable Checks\u{26A1}", @@ -751,6 +1013,7 @@ const LocaleTranslationData = { "edit.unit": "Details/Edit Unit\u{26A1}", "edit.user": "Modifier l'utilisateur", "edited": "édité", + "edit.segment.title": "Modifier le segment", "enable": "Activer", "error.bar": "Show error bars\u{26A1}", "error.bounds": "Must be between {min} and {max}.\u{26A1}", @@ -816,6 +1079,11 @@ const LocaleTranslationData = { "help.admin.conversioncreate": "This page allows admins to create conversions. Please visit {link} for further details and information.\u{26A1}", "help.admin.conversionedit": "This page allows admins to edit conversions. Please visit {link} for further details and information.\u{26A1}", "help.admin.conversionview": "This page shows information on conversions. Please visit {link} for further details and information.\u{26A1}", + "help.admin.daycreate": "This page allows admins to create days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayedit": "This page allows admins to edit days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayview": "This page shows information on days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.holidayview": "This page allows admins to import and view holidays. Please visit {link} for further details and information.\u{26A1}", + "help.admin.holidayrateview": "This page shows information on holiday rates. Please visit {link} for further details and information.\u{26A1}", "help.admin.groupcreate": "This page allows admins to create goups. Please visit {link} for further details and information.\u{26A1}", "help.admin.groupedit": "This page allows admins to edit groups. Please visit {link} for further details and information.\u{26A1}", "help.admin.groupview": "This page allows admins to view groups. Please visit {link} for further details and information.\u{26A1}", @@ -829,6 +1097,9 @@ const LocaleTranslationData = { "help.admin.unitedit": "This page allows admins to edit units. Please visit {link} for further details and information.", "help.admin.unitview": "This page shows information on units. Please visit {link} for further details and information.\u{26A1}", "help.admin.users": "This page allows admins to view and edit users. Please visit {link} for further details and information.\u{26A1}", + "help.admin.weekcreate": "This page allows admins to create weeks. Please visit {link} for further details and information.\u{26A1}", + "help.admin.weekedit": "This page allows admins to edit weeks. Please visit {link} for further details and information.\u{26A1}", + "help.admin.weekview": "This page allows admins to view and edit weeks. Please visit {link} for further details and information.\u{26A1}", "help.csv.meters": "Cette page permet aux administrateurs de téléverser des mètres via un fichier CSV. Veuillez visiter {link} pour plus de détails et d'informations.\u{26A1}", "help.csv.readings": "Cette page permet à certains utilisateurs de télécharger des lectures via un fichier CSV. Veuillez visiter {link} pour plus de détails et d'informations.\u{26A1}", "help.groups.area.calculate": "This will sum together the area of all meters in this group with a nonzero area with an area unit. It will ignore any meters which have no area or area unit. If this group has no area unit, it will do nothing.\u{26A1}", @@ -861,6 +1132,69 @@ const LocaleTranslationData = { "hide": "Cacher", "hide.options": "Options de quai", "hide.options.in.link": "Hide options in link\u{26A1}", + "holiday.base": "Base Holiday:\u{26A1}", + "holiday.base.changed.warning": "Base holiday changed - make sure the name still matches.\u{26A1}", + "holiday.base.required": "Base holiday is required\u{26A1}", + "holiday.base.select": "Select or search for a holiday...\u{26A1}", + "holiday.base.unknown": "(unknown holiday)\u{26A1}", + "holiday.country": "Country\u{26A1}", + "holiday.country.select": "Select a country\u{26A1}", + "holiday.date": "Date:\u{26A1}", + "holiday.day.name": "Holiday Day Name:\u{26A1}", + "holiday.history.warning": "The holiday source does not reliably identify when holidays began or ended. Review historical schedules before using these holidays for past data.\u{26A1}", + "holiday.instances": "Tarifs de jours fériés : ", + "holiday.import.button": "Import/Update Holidays\u{26A1}", + "holiday.import.country.required": "Select a country before importing holidays.\u{26A1}", + "holiday.import.failure": "Failed to import holidays: \u{26A1}", + "holiday.import.success": "Holidays imported/updated: \u{26A1}", + "holiday.instance.group.create": "Créer un groupe de tarifs de jours fériés", + "holiday.instance.group.create.failure": "Failed to create holiday rate group.", + "holiday.instance.group.create.success": "Successfully created holiday rate group. \u{26A1}", + "holiday.instance.group.delete": "Supprimer le groupe de tarifs de jours fériés", + "holiday.instance.group.delete.confirm": "Are you sure you want to delete this holiday rate group?\u{26A1}", + "holiday.instance.group.delete.failure": "Failed to delete holiday rate group. \u{26A1}", + "holiday.instance.group.delete.success": "Successfully deleted holiday rate group. \u{26A1}", + "holiday.instance.group.details": "Détails/Modifier le groupe", + "holiday.instance.group.edit": "Modifier le groupe de tarifs de jours fériés", + "holiday.instance.group.edit.failure": "Failed to edit holiday rate group.", + "holiday.instance.group.edit.success": "Successfully edited holiday rate group. \u{26A1}", + "holiday.instance.group.empty": "None\u{26A1}", + "holiday.instance.groups": "Groupes de tarifs de jours fériés", + "holiday.instance.group.load.failure": "Unable to load holiday rate group data from the database.\u{26A1}", + "holiday.instance.group.loading": "Loading holiday rate groups...\u{26A1}", + "holiday.location": "Location:\u{26A1}", + "holiday.none": "No holidays are stored for this location and year.\u{26A1}", + "holiday.rate.create": "Create a Holiday Rate\u{26A1}", + "holiday.rate.create.failure": "Failed to create a holiday rate with error: \u{26A1}", + "holiday.rate.create.save": "Create Holiday Rate\u{26A1}", + "holiday.rate.create.success": "Successfully created a holiday rate.\u{26A1}", + "holiday.rate.delete": "Delete Holiday Rate\u{26A1}", + "holiday.rate.delete.confirm": "Are you sure you want to delete this holiday rate? This cannot be undone.\u{26A1}", + "holiday.rate.delete.failure": "Failed to delete holiday rate with error: \u{26A1}", + "holiday.rate.delete.success": "Successfully deleted holiday rate.\u{26A1}", + "holiday.rate.edit": "Details/Edit Holiday Rate\u{26A1}", + "holiday.rate.edit.failure": "Failed to edit holiday rate with error: \u{26A1}", + "holiday.rate.edit.success": "Successfully edited holiday rate.\u{26A1}", + "holiday.rate.name.required": "Name is required\u{26A1}", + "holiday.rate.none": "No holiday rates have been created yet.\u{26A1}", + "holiday.rate.save": "Save Holiday Rate\u{26A1}", + "holiday.rates": "Holiday Rates\u{26A1}", + "holiday.region": "Region:\u{26A1}", + "holiday.region.none": "None\u{26A1}", + "holiday.region.unavailable": "Indisponible", + "holiday.saved": "Saved Holidays\u{26A1}", + "holiday.state": "State/Province:\u{26A1}", + "holiday.state.select": "No state or province\u{26A1}", + "holiday.type": "Type:\u{26A1}", + "holiday.type.bank": "Bank\u{26A1}", + "holiday.type.custom": "Custom\u{26A1}", + "holiday.type.observance": "Observance\u{26A1}", + "holiday.type.optional": "Optional\u{26A1}", + "holiday.type.public": "Public\u{26A1}", + "holiday.type.school": "School\u{26A1}", + "holiday.year": "Year:\u{26A1}", + "holiday.year.invalid": "Enter a valid year.\u{26A1}", + "holidays": "Holidays\u{26A1}", "home": "Accueil", "hour": "Hour\u{26A1}", "identifier": "Identifier:\u{26A1}", @@ -871,10 +1205,13 @@ const LocaleTranslationData = { "increasing": "increasing\u{26A1}", "info": " pour plus d'informations. ", "initializing": "Initializing\u{26A1}", + "initial.conversion": "Initial Conversion\u{26A1}", + "initial.segment": "Initial Segment\u{26A1}", "input.gps.coords.first": "input GPS coordinate that corresponds to the point: \u{26A1}", "input.gps.coords.second": "in this format -> latitude,longitude\u{26A1}", "input.gps.range": "Coordonnée GPS invalide, la latitude doit être un nombre entier entre -90 et 90, la longitude doit être un nombre entier entre -180 et 180. You input: \u{26A1}", "insufficient.readings": "Données de lectures insuffisantes pour la comparaison de processus pour ", + "intercept": "Intercept\u{26A1}", "invalid.input": "Invalid input\u{26A1}", "invalid.number": "Please submit a valid number (between 0 and 2.0)\u{26A1}", "invalid.token.login": "Le jeton a expiré. Connectez-vous à nouveau.", @@ -988,7 +1325,7 @@ const LocaleTranslationData = { "no": "no\u{26A1}", "no.data.in.range": "No Data In Date Range\u{26A1}", "no.logs": "No logs to display. Please select another log type or date range\u{26A1}", - "note": "Noter: ", + "note": "Noter:", "num.logs.display": "Number of logs to display\u{26A1}", "oed": "Tableau de Bord Ouvert d'énergie", "oed.description": "Le Tableau de Bord Ouvert d'énergie est un projet open source indépendant. ", @@ -1001,6 +1338,8 @@ const LocaleTranslationData = { "page.user.refresh.directions": "If clicking the \"Return to Dashboard\" button does not work then please click the button below to restart your OED session\u{26A1}", "password": "Mot de passe: ", "password.confirm": "Confirm password: \u{26A1}", + "patterns.daily": "Patterns - Daily\u{26A1}", + "patterns.weekly": "Modèles - Hebdomadaires", "per.day": "Per Day\u{26A1}", "per.hour": "Per Hour\u{26A1}", "per.minute": "Per Minute\u{26A1}", @@ -1030,7 +1369,9 @@ const LocaleTranslationData = { "save.role.changes": "Save role changes\u{26A1}", "second": "Second\u{26A1}", "select.all": "Select All\u{26A1}", + "select.day": "Sélectionnez le Jour", "select.groups": "Sélectionnez des Groupes", + "select.holiday.rates": "Select Holiday Rates\u{26A1}", "select.map": "Select Map\u{26A1}", "select.meter": "Sélectionnez de Mètres", "select.meter.group": "Select meter or group to graph\u{26A1}", @@ -1038,16 +1379,23 @@ const LocaleTranslationData = { "select.meters": "Sélectionnez des Mètres", "select.shift.amount": "Select shift amount\u{26A1}", "select.unit": "Select Unit\u{26A1}", + "segment.note": "Segment Note:\u{26A1}", "shift.date.interval": "Shift Date Interval\u{26A1}", "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately\u{26A1}", "show": "Montrer", "show.all.logs": "Show All Logs\u{26A1} ", + "show.all.segments": "Afficher tous les segments", "show.grid": "Show grid\u{26A1}", "show.options": "Options de désancrage", "show.in.pages": "Show in Pages\u{26A1}", "site.settings": "Site Settings\u{26A1}", "site.title": "Site Title\u{26A1}", + "slope": "Pente", "sort": "Sort Order\u{26A1}", + "split.earlier": "Diviser\u2191", + "split.later": "Diviser\u2193", + "split.hour.prompt": "Entrez l'heure pour diviser le segment", + "split.hour.invalid": "Veuillez entrer une heure valide entre {start} et {end}.", "submit": "Soumettre", "submit.changes": "Soumettre les changements", "submit.new.user": "Submit new user\u{26A1}", @@ -1139,6 +1487,18 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics\u{26A1}", "website": "site web", "week": "Semaine", + "week.create": "Créer une Semaine", + "week.create.failure": "Échec de la création de la semaine avec le message: ", + "week.create.success": "Semaine créée avec succès", + "week.delete": "Supprimer la Semaine", + "week.delete.confirm": "Êtes-vous sûr de vouloir supprimer cette semaine?", + "week.delete.failure": "Échec de la suppression de la semaine avec le message: ", + "week.delete.success": "Semaine supprimée avec succès", + "week.edit": "Éditer Semaine", + "week.edit.failure": "Échec de la modification de la semaine avec le message: ", + "week.edit.success": "Semaine modifiée avec succès", + "week.validation.name.exists": "Cette semaine existe déjà", + "weeks": "Semaines", "yes": " yes\u{26A1}", "yesterday": "Hier", "you.cannot.create.a.cyclic.group": "Vous ne pouvez pas créer un groupe cyclique", @@ -1210,35 +1570,75 @@ const LocaleTranslationData = { "conversion.create.destination.meter": "La destinación no puede ser un medidor", "conversion.create.exists": "Esta conversión ya existe", "conversion.create.exists.inverse": "Esta conversión ya existe en una que no es bidireccional", + "conversion.create.failure": "Failed to create conversion with message: \u{26A1}", "conversion.create.mixed.represent": "Una conversión no puede mezclar unidades de cantidad, de flujo y crudas.", "conversion.create.source.destination.not": "Fuente o destinación no establecida.", "conversion.create.source.destination.same": "La fuente y destinación no pueden ser la misma para una conversión", + "conversion.create.success": "Conversión creada con éxito.", "conversion.delete.conversion": "Eliminar conversión", "conversion.delete.meter.orphan": "Deleting this meter conversion will orphan (make ungraphable) meter unit\u{26A1}", "conversion.delete.meter.related": "The following meters use meter unit\u{26A1}", "conversion.delete.meter.reduce.graphable": "Deleting this meter conversion will reduce the number of graphable units for meters using meter unit\u{26A1}", "conversion.delete.restricted": "This conversion cannot be deleted until the significant consequences of doing this are addressed.\u{26A1}", "conversion.delete.suffix.disable": "Deleting this suffix conversion will disable use of suffix\u{26A1}", - "conversion.delete.unit" : "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.\u{26A1}", + "conversion.delete.segment.earlier": "¿Está seguro de que desea eliminar el segmento de {start} a {end} y actualizar el segmento anterior?", + "conversion.delete.segment.later": "¿Está seguro de que desea eliminar el segmento de {start} a {end} y actualizar el siguiente segmento?", + "conversion.delete.unit": "Deleting this conversion between two units of type unit may reduce the possible graphing units for some meter(s) but cannot be known until after the deletion happens.\u{26A1}", "conversion.delete.unit.orphan": "Deleting this unit conversion will orphan unit\u{26A1}", "conversion.destination": "Destinación", "conversion.dropdown.displayable.option.admin": "Administrador", "conversion.edit.conversion": "Editar conversión", + "conversion.edit.segment": "Editar segmento de conversión", + "conversion.error.datetime.invalid": "Formato inválido. Use YYYY-MM-DD HH:mm:ss", + "conversion.error.segment.splitTime": "La hora de división debe estar estrictamente entre el inicio y el fin del segmento.", + "conversion.error.split.beforeStart": "La hora de división debe estar después de la hora de inicio de este segmento", + "conversion.error.split.afterEnd": "La hora de división debe estar antes de la hora de fin de este segmento", + "conversion.error.split.time.invalid": "Hora de división no válida. Utilice el formato AAAA-MM-DD o AAAA-MM-DD HH:mm:ss.", "conversion.failed.to.create.conversion": "No se pudo crear la conversión.", "conversion.failed.to.delete.conversion": "No se pudo eliminar la conversión", "conversion.failed.to.edit.conversion": "No se pudo editar la conversión", - "conversion.intercept": "Interceptar:", "conversion.is.deleted": "Se borró la conversión en la base de datos", + "conversion.note": "Nota", + "conversion.overall": "Conversión General", + "conversion.overall.delete": "Eliminar la conversión general", + "conversion.overall.discard": "Descartar los cambios generales", + "conversion.overall.save": "Guardar la conversión general", + "conversion.overall.save.error": "Error al actualizar la conversión.", + "conversion.overall.save.success": "Conversión actualizada correctamente.", + "conversion.overall.subtitle": "Esta sección define la conversión general entre unidades.", + "conversion.pattern": "Pattern\u{26A1}", + "conversion.pattern.no": "No Pattern\u{26A1}", "conversion.represent": "La conversión representa:", + "conversion.segment.delete.error": "Ocurrió un error al eliminar el segmento.", + "conversion.segment.delete.success": "Segmento de conversión eliminado correctamente.", + "conversion.segment.split.error": "Ocurrió un error al dividir el segmento.", + "conversion.segment.split.success": "Segmento de conversión dividido con éxito.", + "conversion.segment.save.error": "Ocurrió un error al guardar el segmento.", + "conversion.segment.save.success": "Segmento guardado correctamente.", + "conversion.segment.warning.invalidStartRange": "La hora de inicio debe ser posterior a la hora de finalización del segmento anterior y anterior a la hora de finalización de este segmento", + "conversion.segment.warning.invalidEndRange": "La hora de finalización debe ser anterior a la hora de inicio del siguiente segmento y posterior a la hora de inicio de este segmento", + "conversion.segment.warning.previousAdjusted": "La hora de finalización del segmento anterior se ajustó para coincidir con la nueva hora de inicio", + "conversion.segment.warning.previousAdjustFailed": "No se pudo ajustar el segmento anterior.", + "conversion.segment.warning.nextAdjusted": "La hora de inicio del siguiente segmento se ajustó para coincidir con la nueva hora de finalización", + "conversion.segment.warning.nextAdjustFailed": "No se pudo ajustar el siguiente segmento.", + "conversion.segments.table": "Tabla de segmentos de conversión", + "conversion.segments.table.subtitle": "Esta sección muestra cómo se comporta la conversión durante períodos específicos.", "conversion.select.destination": "Seleccionar una unidad de destinación", "conversion.select.source": "Seleccionar una unidad de fuente", - "conversion.slope": "Pendiente:", "conversion.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this conversion?\u{26A1}", "conversion.source": "Fuente:", + "conversion.split.datetime.placeholder": "AAAA-MM-DD HH:mm:ss (completo)", + "conversion.split.datetime.prompt": "Ingrese la fecha y hora (AAAA-MM-DD HH:mm:ss) para dividir el segmento:", "conversion.submit.new.conversion": "Ingresar una nueva conversión", "conversion.successfully.create.conversion": "Se creó una conversión con éxito.", "conversion.successfully.delete.conversion": "Se eliminó una conversión con éxito.", "conversion.successfully.edited.conversion": "Se editó una conversión con éxito", + "conversion.table.delete.earlier": "Eliminar anterior\u2191", + "conversion.table.delete.later": "Eliminar siguiente\u2193", + "conversion.table.split.earlier": "Dividir antes\u2191", + "conversion.table.split.later": "Dividir después\u2193", + "conversion.time.start": "Hora de inicio", + "conversion.time.end": "Hora de finalización", "conversions": "Conversiones", "ConversionType.conversion": "conversión", "create.conversion": "Crear una conversión", @@ -1276,6 +1676,45 @@ const LocaleTranslationData = { "custom.value": "Valor personalizado", "date.range": 'Rango de fechas', "day": "Día", + "day.create": "Crear un Día", + "day.create.failure": "No se pudo crear el día con el error: ", + "day.create.name.exists": "El día ya existe", + "day.create.name.required": "El nombre del día es requerido", + "day.create.success": "Día creado con éxito", + "day.delete.button": "Eliminar el Día General", + "day.delete.confirm": "¿Estás seguro de que quieres eliminar este día?", + "day.delete.error": "No se pudo eliminar el día.", + "day.delete.success": "Día eliminado con éxito.", + "day.edit": "Detalles/Editar Día", + "day.edit.discard": "Descartar los cambios generales", + "day.edit.error": "No se pudo editar el día.", + "day.edit.save": "Guardar el Día General", + "day.edit.success": "Día editado con éxito.", + "day.end.hour": "Hora de finalización", + "day.friday": "Viernes", + "day.monday": "Lunes", + "day.of.week": "Día de la semana", + "day.pattern": "Day Pattern:\u{26A1}", + "day.pattern.required": "Day pattern is required\u{26A1}", + "day.pattern.select": "Select a day pattern\u{26A1}", + "day.saturday": "Sábado", + "day.segments.delete.confirm.earlier": "¿Estás seguro de que quieres eliminar el segmento de {startHour} - {endHour} y actualizar el segmento anterior?", + "day.segments.delete.confirm.later": "¿Estás seguro de que quieres eliminar el segmento de {startHour} - {endHour} y actualizar el segmento siguiente?", + "day.segments.delete.confirm.button": "Eliminar segmento", + "day.segments.delete.invalid.direction": "Invalid direction for segment deletion: {direction}\u{26A1}", + "day.segments.delete.success": "Successfully deleted day segment.\u{26A1}", + "day.segments.edit.start.hour.invalid": "Must be an integer greater than {min} and less than the end hour\u{26A1}", + "day.segments.edit.end.hour.invalid": "Must be an integer greater than the start hour and less than {max}\u{26A1}", + "day.segments.edit.save": "Guardar segmento", + "day.segments.split.success": "Successfully split day segment.\u{26A1}", + "day.segments.table.timeRange": "Rango horario", + "day.segments.table.title": "Tabla de segmentos del día", + "day.slope.intercept.zero": "Tanto la pendiente como la intersección son cero. ¿Seguro que quieres guardar este segmento?", + "day.start.hour": "Hora de inicio", + "day.sunday": "Domingo", + "day.thursday": "Jueves", + "day.tuesday": "Martes", + "day.wednesday": "Miércoles", "days": "Días", "days.enter": "Ingrese los días y presione \"Enter\"", "decreasing": "decreciente", @@ -1298,10 +1737,13 @@ const LocaleTranslationData = { "default.time.zone": "Zona de horario predeterminada", "default.warning.file.size": "Advertencia predeterminada de tamaño del archivo", "defaultGraphicUnit": "Unidad del gráfico predeterminada:", + "delete.earlier": "Eliminar\u2191", "delete.group": "Borrar grupo", + "delete.later": "Eliminar\u2193", "delete.map": "Borrar mapa", "delete.self": "No puedes eliminar tu propia cuenta.", "delete.user": "Borrar usario", + "delete.week": "Borrar semana", "descending": "Descendente", "disable": "Desactivar", "disable.checks": "Disable Checks\u{26A1}", @@ -1321,6 +1763,7 @@ const LocaleTranslationData = { "edit.unit": "Details/Edit Unit\u{26A1}", "edit.user": "Editar Usuario", "edited": "editado", + "edit.segment.title": "Editar segmento", "enable": "Activar", "error.bar": "Mostrar las barras de errores", "error.bounds": "Debe ser entre {min} y {max}.", @@ -1386,6 +1829,11 @@ const LocaleTranslationData = { "help.admin.conversioncreate": "Esta página permite a los administradores crear conversiones. Por favor visite {link} para más detalles e información.", "help.admin.conversionedit": "Esta página permite a los administradores editar conversiones. Por favor visite {link} para más detalles e información", "help.admin.conversionview": "Esta página muestra información de conversiones. Por favor visite {link} para más detalles e información.", + "help.admin.daycreate": "This page allows admins to create days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayedit": "This page allows admins to edit days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayview": "This page shows information on days. Please visit {link} for further details and information.\u{26A1}", + "help.admin.holidayview": "This page allows admins to import and view holidays. Please visit {link} for further details and information.\u{26A1}", + "help.admin.holidayrateview": "This page shows information on holiday rates. Please visit {link} for further details and information.\u{26A1}", "help.admin.groupcreate": "Esta página permite a los administradores crear grupos. Por favor visite {link} para más detalles e información.", "help.admin.groupedit": "Esta página permite a los administradores editar grupos. Por favor visite {link} para más detalles e información.", "help.admin.groupview": "Esta página permite a los administradores ver grupos. Por favor, visite {link} para más detalles e información.", @@ -1399,6 +1847,9 @@ const LocaleTranslationData = { "help.admin.unitedit": "Esta página permite a los administradores editar unidades Por favor, visite {link} para más detalles e información.", "help.admin.unitview": "Esta página muestra información sobre unidades. Por favor, visite {link} para más detalles e información.", "help.admin.users": "Esta página permite a los administradores ver y editar usarios. Por favor, visite {link} para más detalles e información.", + "help.admin.weekcreate": "Esta página permite a los administradores crear patrones semanales. Por favor, visite {link} para más detalles e información.", + "help.admin.weekedit": "Esta página permite a los administradores editar patrones semanales. Por favor, visite {link} para más detalles e información.", + "help.admin.weekview": "Esta página permite a los administradores ver y editar patrones semanales. Por favor, visite {link} para más detalles e información.", "help.csv.meters": "Esta página permite a los administradores cargar medidores a través de un archivo CSV. Visite {enlace} para obtener más detalles e información.", "help.csv.readings": "Esta página permite a ciertos usuarios cargar lecturas a través de un archivo CSV. Visite {link} para obtener más detalles e información.", "help.groups.area.calculate": "Esto sumará el área de todos los medidores en este grupo que tengan un área distinta a cero y una unidad de área. Ignorará cualquier medidor que no tiene área o unidad de área. Si este grupo no tiene unidad de área, no hará nada.", @@ -1431,6 +1882,69 @@ const LocaleTranslationData = { "hide": "Esconder", "hide.options": "Esconder las opciones", "hide.options.in.link": "Esconder las opciones en el enlace", + "holiday.base": "Base Holiday:\u{26A1}", + "holiday.base.changed.warning": "Base holiday changed - make sure the name still matches.\u{26A1}", + "holiday.base.required": "Base holiday is required\u{26A1}", + "holiday.base.select": "Select or search for a holiday...\u{26A1}", + "holiday.base.unknown": "(unknown holiday)\u{26A1}", + "holiday.country": "Country\u{26A1}", + "holiday.country.select": "Select a country\u{26A1}", + "holiday.date": "Date:\u{26A1}", + "holiday.day.name": "Holiday Day Name:\u{26A1}", + "holiday.history.warning": "The holiday source does not reliably identify when holidays began or ended. Review historical schedules before using these holidays for past data.\u{26A1}", + "holiday.import.button": "Import/Update Holidays\u{26A1}", + "holiday.import.country.required": "Select a country before importing holidays.\u{26A1}", + "holiday.import.failure": "Failed to import holidays: \u{26A1}", + "holiday.import.success": "Holidays imported/updated: \u{26A1}", + "holiday.instance.group.create": "Crear grupo de tarifas de días festivos", + "holiday.instance.group.create.failure": "Failed to create holiday rate group. \u{26A1}", + "holiday.instance.group.create.success": "Successfully created holiday rate group. \u{26A1}", + "holiday.instance.group.delete": "Eliminar grupo de tarifas de días festivos", + "holiday.instance.group.delete.confirm": "Are you sure you want to delete this holiday rate group?\u{26A1}", + "holiday.instance.group.delete.failure": "Failed to delete holiday rate group. \u{26A1}", + "holiday.instance.group.delete.success": "Successfully deleted holiday rate group. \u{26A1}", + "holiday.instance.group.details": "Detalles/Editar grupo", + "holiday.instance.group.edit": "Editar grupo de tarifas de días festivos", + "holiday.instance.group.edit.failure": "Failed to edit holiday rate group. \u{26A1}", + "holiday.instance.group.edit.success": "Successfully edited holiday rate group. \u{26A1}", + "holiday.instance.group.empty": "None\u{26A1}", + "holiday.instance.groups": "Grupos de tarifas de días festivos", + "holiday.instance.group.load.failure": "Unable to load holiday rate group data from the database.\u{26A1}", + "holiday.instance.group.loading": "Loading holiday rate groups...\u{26A1}", + "holiday.instances": "Tarifas de días festivos: ", + "holiday.location": "Location:\u{26A1}", + "holiday.none": "No holidays are stored for this location and year.\u{26A1}", + "holiday.rate.create": "Create a Holiday Rate\u{26A1}", + "holiday.rate.create.failure": "Failed to create a holiday rate with error: \u{26A1}", + "holiday.rate.create.save": "Create Holiday Rate\u{26A1}", + "holiday.rate.create.success": "Successfully created a holiday rate.\u{26A1}", + "holiday.rate.delete": "Delete Holiday Rate\u{26A1}", + "holiday.rate.delete.confirm": "Are you sure you want to delete this holiday rate? This cannot be undone.\u{26A1}", + "holiday.rate.delete.failure": "Failed to delete holiday rate with error: \u{26A1}", + "holiday.rate.delete.success": "Successfully deleted holiday rate.\u{26A1}", + "holiday.rate.edit": "Details/Edit Holiday Rate\u{26A1}", + "holiday.rate.edit.failure": "Failed to edit holiday rate with error: \u{26A1}", + "holiday.rate.edit.success": "Successfully edited holiday rate.\u{26A1}", + "holiday.rate.name.required": "Name is required\u{26A1}", + "holiday.rate.none": "No holiday rates have been created yet.\u{26A1}", + "holiday.rate.save": "Save Holiday Rate\u{26A1}", + "holiday.rates": "Holiday Rates\u{26A1}", + "holiday.region": "Region:\u{26A1}", + "holiday.region.none": "None\u{26A1}", + "holiday.region.unavailable": "No disponible", + "holiday.saved": "Saved Holidays\u{26A1}", + "holiday.state": "State/Province:\u{26A1}", + "holiday.state.select": "No state or province\u{26A1}", + "holiday.type": "Type:\u{26A1}", + "holiday.type.bank": "Bank\u{26A1}", + "holiday.type.custom": "Custom\u{26A1}", + "holiday.type.observance": "Observance\u{26A1}", + "holiday.type.optional": "Optional\u{26A1}", + "holiday.type.public": "Public\u{26A1}", + "holiday.type.school": "School\u{26A1}", + "holiday.year": "Year:\u{26A1}", + "holiday.year.invalid": "Enter a valid year.\u{26A1}", + "holidays": "Holidays\u{26A1}", "home": "Inicio", "hour": "Hora", "identifier": "Identificador:", @@ -1441,10 +1955,13 @@ const LocaleTranslationData = { "increasing": "aumentando", "info": " para obtener más información. ", "initializing": "Inicializando", + "initial.conversion": "Initial Conversion\u{26A1}", + "initial.segment": "Initial Segment\u{26A1}", "input.gps.coords.first": "Indique las coordenada de GPS que corresponden al punto: ", "input.gps.coords.second": "de esta forma -> latitud, longitud", "input.gps.range": "Coordenada GPS no válida, la latitud debe ser un número entero entre -90 y 90, la longitud debe ser un número entero entre -180 y 180. Usted puso: ", "insufficient.readings": "Hay insuficientes datos de lecturas para procesar la comparación de ", + "intercept": "Intercept\u{26A1}", "invalid.input": "Invalid input\u{26A1}", "invalid.number": "Por favor indique un número válido (entre 0 a 2.0)", "invalid.token.login": "El token se ha vencido. Inicie la sesión nuevamente", @@ -1558,7 +2075,7 @@ const LocaleTranslationData = { "no": "no", "no.data.in.range": "No hay datos para el rango de fechas", "no.logs": "No logs to display. Please select another log type or data range\u{26A1}", - "note": "Nota: ", + "note": "Nota:", "num.logs.display": "Number of logs to display\u{26A1}", "oed": "Panel de Energía Abierto", "oed.description": "Open Energy Dashboard es un proyecto independiente. ", @@ -1571,6 +2088,8 @@ const LocaleTranslationData = { "page.user.refresh.directions": "If clicking the \"Return to Dashboard\" button does not work then please click the button below to restart your OED session\u{26A1}", "password": "Contraseña: ", "password.confirm": "Confirmar contraseña: ", + "patterns.daily": "Patrones - Diarios", + "patterns.weekly": "Patrones - Semanales", "per.day": "Por día", "per.hour": "Por hora", "per.minute": "Por minuto", @@ -1600,7 +2119,9 @@ const LocaleTranslationData = { "save.role.changes": "Guardar los cambios de rol", "second": "Segundo", "select.all": "Select All\u{26A1}", + "select.day": "Seleccionar Día", "select.groups": "Seleccionar grupos", + "select.holiday.rates": "Select Holiday Rates\u{26A1}", "select.map": "Seleccionar mapa", "select.meter": "Seleccionar medidor", "select.meter.group": "Seleccionar medidor o grupo para hacer gráfico", @@ -1608,16 +2129,23 @@ const LocaleTranslationData = { "select.meters": "Seleccionar medidores", "select.shift.amount": "Select shift amount\u{26A1}", "select.unit": "Seleccionar unidad", + "segment.note": "Segment Note:\u{26A1}", "shift.date.interval": "Shift Date Interval\u{26A1}", "shifted.data.crosses.leap.year.to.non.leap.year": "Shifted data crosses a leap year so the graph might not align appropriately\u{26A1}", "show": "Mostrar", "show.all.logs": "Show All Logs\u{26A1} ", + "show.all.segments": "Mostrar todos los segmentos", "show.grid": "Mostrar rejilla", "show.options": "Mostrar opciones", "show.in.pages": "Show in Pages\u{26A1}", "site.settings": "Site Settings\u{26A1}", "site.title": "Site Title\u{26A1}", + "slope": "Pendiente", "sort": "Sort Order\u{26A1}", + "split.earlier": "Dividir\u2191", + "split.later": "Dividir\u2193", + "split.hour.prompt": "Ingrese la hora para dividir el segmento", + "split.hour.invalid": "Por favor ingrese una hora válida entre {start} y {end}.", "submit": "Enviar", "submit.changes": "Ingresar los cambios", "submit.new.user": "Ingresar un nuevo usario", @@ -1712,6 +2240,18 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics\u{26A1}", "website": "sitio web", "week": "semana", + "week.create": "Crear una Semana", + "week.create.failure": "No se pudo crear la semana con el mensaje: ", + "week.create.success": "Semana creada con éxito.", + "week.delete": "Borrar Semana", + "week.delete.confirm": "¿Estás seguro de que quieres borrar la semana?", + "week.delete.failure": "No se pudo borrar la semana con el mensaje: ", + "week.delete.success": "Semana borrada con éxito.", + "week.edit": "Editar Semana", + "week.edit.failure": "No se pudo editar la semana con el mensaje: ", + "week.edit.success": "Semana editada con éxito.", + "week.validation.name.exists": "Ya existe una semana con este nombre.", + "weeks": "Semanas", "yes": "sí", "yesterday": "Ayer", "you.cannot.create.a.cyclic.group": "No se puede crear un grupo cíclico", diff --git a/src/client/app/types/redux/conversionSegments.ts b/src/client/app/types/redux/conversionSegments.ts new file mode 100644 index 0000000000..a4f41cd6c7 --- /dev/null +++ b/src/client/app/types/redux/conversionSegments.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/. + */ + +/** + * Defines a time-based conversion segment with a slope, intercept, note + * and optional week pattern for a given time range. + */ +export interface ConversionSegmentData { + sourceId: number; + destinationId: number; + startTime: string; + endTime: string; + slope: number; + intercept: number; + weekPatternsId: number; + note: string; +} + +/** + * Payload used when updating an existing conversion segment. + * Includes the original start and end times to uniquely identify + * the segment in the database, since startTime and endTime are part of the primary key. + */ +export interface UpdateConversionSegmentPayload extends ConversionSegmentData { + originalStartTime: string; + originalEndTime: string; +} + +/** + * Payload used when splitting a conversion segment into two. + * Includes the original segment details (source, destination, start/end time), + * the split point (`splitTime`), and values for the new segment that will be created + * after the split (slope, intercept, week pattern, and optional note). + */ +export interface SplitConversionSegmentPayload { + sourceId: number; + destinationId: number; + startTime: string; + endTime: string; + splitTime: string; + newSlope: number; + newIntercept: number; + newWeekPatternsId: number; + newNote?: string; +} diff --git a/src/client/app/types/redux/conversions.ts b/src/client/app/types/redux/conversions.ts index 7a3f245985..d0714a6552 100644 --- a/src/client/app/types/redux/conversions.ts +++ b/src/client/app/types/redux/conversions.ts @@ -6,8 +6,6 @@ export interface ConversionData { sourceId: number; destinationId: number; bidirectional: boolean; - slope: number; - intercept: number; note: string; } diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts new file mode 100644 index 0000000000..d8b1dd4350 --- /dev/null +++ b/src/client/app/types/redux/days.ts @@ -0,0 +1,55 @@ +/* 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 Day { + id: number; + name: string; + note: string; +} + +export interface DaySegment { + id: number; + dayId: number; + startHour: number; + endHour: number; + slope: number; + intercept: number; + note?: string; +} + +export interface CreateDayPayload { + /** + * Day name + */ + name: string; + /** + * Day note + */ + note?: string; + /** + * Initial segment slope + */ + slope: number; + /** + * Initial segment intercept + */ + intercept: number; + /** + * Initial segment note + */ + segmentNote?: string; +} + +export interface UpdateDaySegmentPayload extends DaySegment { + originalStartHour: number; + originalEndHour: number; +} + +export interface SplitDaySegmentPayload { + id: number; + newSlope: number; + newIntercept: number; + newNote?: string; + splitTime: number; +} diff --git a/src/client/app/types/redux/holiday.ts b/src/client/app/types/redux/holiday.ts new file mode 100644 index 0000000000..724331847b --- /dev/null +++ b/src/client/app/types/redux/holiday.ts @@ -0,0 +1,30 @@ +/* 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 HolidayInstance { + id: number; + name: string; + note: string; + holidayId: number; + dayPatternId: number; +} + +export interface HolidayInstanceGroup { + id: number; + name: string; + note: string; +} + +export interface HolidayGroupMember { + holidayInstanceGroupId: number; + holidayInstanceId: number; +} + +export interface Holidays { + id: number; + name: string; + startDate: string; + local: string; + note: string; +} \ No newline at end of file diff --git a/src/client/app/types/redux/holidays.ts b/src/client/app/types/redux/holidays.ts new file mode 100644 index 0000000000..c2067e895e --- /dev/null +++ b/src/client/app/types/redux/holidays.ts @@ -0,0 +1,122 @@ +/* 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/. */ + +/** + * A holiday row from the `holidays` table. + * Matches formatHolidayForResponse in routes/holidays.js. + */ +export interface Holiday { + id: number; + name: string; + /** ISO date string from the DATE column */ + startDate: string; + /** Free-text location; TODO: may become a region table later */ + location: string; + type: string; + note?: string | null; +} + +/** + * A holiday instance row ("Holiday Rate" in the UI) from `holiday_instance`. + * Matches formatHolidayInstanceForResponse in routes/holidayInstances.js. + * + * DB constraints to mirror in the UI: + * - name is UNIQUE across all instances + * - (holidayId, dayPatternId) pair is UNIQUE + */ +export interface HolidayInstance { + id: number; + name: string; + holidayId: number; + dayPatternId: number; + note?: string | null; +} + +/** + * Create payload for POST /api/holidayInstances/addHolidayInstance. + * No id — the server assigns it and returns the created instance. + */ +export type CreateHolidayInstancePayload = Omit; + +/** + * A holiday instance joined with its holiday and day pattern, from + * GET /api/holidayInstances/withDetails. Handy for card display without + * client-side joins. + */ +export interface HolidayInstanceDetails extends HolidayInstance { + holidayName: string; + startDate: string; + location: string; + dayPatternName: string; +} + +/** + * A holiday instance group together with the IDs stored in + * `holiday_group_members` for that group. + */ +export interface HolidayInstanceGroup { + id: number; + name: string; + holidayInstanceIds: number[]; + note: string; +} + +export type CreateHolidayInstanceGroupPayload = Omit; + +export type UpdateHolidayInstanceGroupPayload = HolidayInstanceGroup; + +/** Joined member response from GET /api/holidayGroupMembers/group/:groupId. */ +export interface HolidayGroupMember { + holidayInstanceGroupId: number; + holidayInstanceId: number; + holidayInstanceName: string; + holidayId: number; + dayPatternId: number; + holidayName: string; + startDate: string; + location: string; + dayPatternName: string; +} + +/** A location option returned by date-holidays for use in a dropdown. */ +export interface HolidayLocationOption { + code: string; + name: string; +} + +/** + * Location options returned by GET /api/holidays/locations. + */ +export interface HolidayLocations { + countries: HolidayLocationOption[]; + states: HolidayLocationOption[]; + regions: HolidayLocationOption[]; +} + +/** + * Optional location selections sent to GET /api/holidays/locations. + */ +export interface HolidayLocationsQuery { + country?: string; + state?: string; +} + +/** + * Location and year sent to POST /api/holidays/refresh. + */ +export interface RefreshHolidaysRequest { + country: string; + state?: string; + region?: string; + year: number; +} + +/** + * Import results returned by POST /api/holidays/refresh. + */ +export interface RefreshHolidaysResponse { + location: string; + year: number; + total: number; +} diff --git a/src/client/app/types/redux/weeks.ts b/src/client/app/types/redux/weeks.ts new file mode 100644 index 0000000000..39ca5caa59 --- /dev/null +++ b/src/client/app/types/redux/weeks.ts @@ -0,0 +1,39 @@ +/* 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 { Day } from './days'; + +/** + * Defines a Week for the weekly conversion patterns. + * A week consists of a name, a note, and references to the seven days that make up the week. + */ +export interface Week { + id: number; + + /** + * The name of the week + */ + name: string; + + /** + * An optional note for the week + */ + note: string; + + /** + * The seven days that make up the week + */ + sunday: Day['id']; + monday: Day['id']; + tuesday: Day['id']; + wednesday: Day['id']; + thursday: Day['id']; + friday: Day['id']; + saturday: Day['id']; + + /** + * An optional holiday instance group applied to this week. + */ + holidayInstanceGroupId?: number | null; +} diff --git a/src/client/app/utils/pagination.ts b/src/client/app/utils/pagination.ts new file mode 100644 index 0000000000..26d8ad744b --- /dev/null +++ b/src/client/app/utils/pagination.ts @@ -0,0 +1,9 @@ +/* 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/. */ + +/** + * Defines shared constants related to pagination across the application. + * Use this value to ensure consistent row counts in tables. + */ +export const ROWS_PER_TABLE = 20; diff --git a/src/server/app.js b/src/server/app.js index 79385ade76..59d2e96024 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -35,6 +35,10 @@ const conversionSegments = require('./routes/conversionSegments'); const days = require('./routes/days'); const daySegments = require('./routes/daySegments'); const weeks = require('./routes/weeks'); +const holidays = require('./routes/holidays'); +const holidayInstances = require('./routes/holidayInstances'); +const holidayInstanceGroups = require('./routes/holidayInstanceGroups'); +const holidayGroupMembers = require('./routes/holidayGroupMembers'); const ciks = require('./routes/ciks'); // Limit the rate of overall requests to OED @@ -141,6 +145,10 @@ app.use('/api/units', units); app.use('/api/conversions', conversions); app.use('/api/days', days); app.use('/api/weeks', weeks); +app.use('/api/holidays', holidays); +app.use('/api/holidayInstances', holidayInstances); +app.use('/api/holidayInstanceGroups', holidayInstanceGroups); +app.use('/api/holidayGroupMembers', holidayGroupMembers); app.use('/api/conversionSegments', conversionSegments); app.use('/api/daySegments', daySegments); app.use('/api/ciks', ciks); diff --git a/src/server/migrations/2.0.0-2.1.0/index.js b/src/server/migrations/2.0.0-2.1.0/index.js new file mode 100644 index 0000000000..82f71474ff --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/index.js @@ -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/. */ + +const database = require('../../models/database'); +const sqlFile = database.sqlFile; + +module.exports = { + fromVersion: '2.0.0', + toVersion: '2.1.0', + up: async db => { + await db.none(sqlFile('../migrations/2.0.0-2.1.0/sql/holiday/create_holidays_table.sql')); + await db.none(sqlFile('../migrations/2.0.0-2.1.0/sql/holidayInstance/create_holiday_instance_table.sql')); + await db.none(sqlFile('../migrations/2.0.0-2.1.0/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql')); + await db.none(sqlFile('../migrations/2.0.0-2.1.0/sql/holidayGroupMember/create_holiday_group_members_table.sql')); + await db.none(sqlFile('../migrations/2.0.0-2.1.0/sql/week/add_holiday_instance_group_id.sql')); + } +}; diff --git a/src/server/migrations/2.0.0-2.1.0/sql/holiday/create_holidays_table.sql b/src/server/migrations/2.0.0-2.1.0/sql/holiday/create_holidays_table.sql new file mode 100644 index 0000000000..e327e3f69c --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/sql/holiday/create_holidays_table.sql @@ -0,0 +1,13 @@ +/* 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 holidays ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) NOT NULL CHECK (char_length(name) >= 1), + start_date DATE NOT NULL, + location TEXT NOT NULL CHECK (char_length(location) >= 1), + type TEXT NOT NULL DEFAULT 'custom' CHECK (char_length(type) >= 1), + note TEXT, + UNIQUE (name, location, start_date) +); diff --git a/src/server/migrations/2.0.0-2.1.0/sql/holidayGroupMember/create_holiday_group_members_table.sql b/src/server/migrations/2.0.0-2.1.0/sql/holidayGroupMember/create_holiday_group_members_table.sql new file mode 100644 index 0000000000..633366f7d8 --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/sql/holidayGroupMember/create_holiday_group_members_table.sql @@ -0,0 +1,9 @@ +/* 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 holiday_group_members ( + holiday_instance_group_id INTEGER NOT NULL REFERENCES holiday_instance_group(id), + holiday_instance_id INTEGER NOT NULL REFERENCES holiday_instance(id), + PRIMARY KEY (holiday_instance_group_id, holiday_instance_id) +); diff --git a/src/server/migrations/2.0.0-2.1.0/sql/holidayInstance/create_holiday_instance_table.sql b/src/server/migrations/2.0.0-2.1.0/sql/holidayInstance/create_holiday_instance_table.sql new file mode 100644 index 0000000000..6a51ebcbe2 --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/sql/holidayInstance/create_holiday_instance_table.sql @@ -0,0 +1,12 @@ +/* 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 holiday_instance ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) UNIQUE NOT NULL CHECK (char_length(name) >= 1), + note TEXT, + holiday_id INTEGER NOT NULL REFERENCES holidays(id), + day_pattern_id INTEGER NOT NULL REFERENCES day_patterns(id), + UNIQUE (holiday_id, day_pattern_id) +); diff --git a/src/server/migrations/2.0.0-2.1.0/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql b/src/server/migrations/2.0.0-2.1.0/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql new file mode 100644 index 0000000000..085f7680f0 --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql @@ -0,0 +1,9 @@ +/* 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 holiday_instance_group ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) UNIQUE NOT NULL CHECK (char_length(name) >= 1), + note TEXT +); diff --git a/src/server/migrations/2.0.0-2.1.0/sql/week/add_holiday_instance_group_id.sql b/src/server/migrations/2.0.0-2.1.0/sql/week/add_holiday_instance_group_id.sql new file mode 100644 index 0000000000..9a7e8ac511 --- /dev/null +++ b/src/server/migrations/2.0.0-2.1.0/sql/week/add_holiday_instance_group_id.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/. */ + +ALTER TABLE week_patterns + ADD COLUMN IF NOT EXISTS holiday_instance_group_id INTEGER REFERENCES holiday_instance_group(id); diff --git a/src/server/migrations/registerMigration.js b/src/server/migrations/registerMigration.js index 2a1f0b74d4..9cc540fa3a 100644 --- a/src/server/migrations/registerMigration.js +++ b/src/server/migrations/registerMigration.js @@ -13,7 +13,8 @@ const migrations = [ require('./0.6.0-0.7.0'), require('./0.7.0-0.8.0'), require('./0.8.0-1.0.0'), - require('./1.0.0-1.1.0') + require('./1.0.0-2.0.0'), + require('./2.0.0-2.1.0') /* eslint-enable global-require */ ]; diff --git a/src/server/models/ConversionSegment.js b/src/server/models/ConversionSegment.js index 2c6104e82e..e1cb20ac9d 100644 --- a/src/server/models/ConversionSegment.js +++ b/src/server/models/ConversionSegment.js @@ -64,7 +64,7 @@ class ConversionSegment { * @returns {Promise.} */ static async getBySourceDestination(sourceId, destinationId, conn) { - const rows = await conn.many(sqlFile('conversionSegment/get_by_source_destination.sql'), { + const rows = await conn.any(sqlFile('conversionSegment/get_by_source_destination.sql'), { sourceId: sourceId, destinationId: destinationId }); @@ -130,7 +130,7 @@ class ConversionSegment { */ static async splitEarlier(sourceId, destinationId, newWeekPatternsId, newSlope, newIntercept, newNote, startTime, endTime, splitTime, conn) { return conn.tx(async t => { - // get all data for the original segment + // get all original values of the segment being split const originalSegment = await t.one(sqlFile('conversionSegment/get_by_source_destination_start_end.sql'), { sourceId: sourceId, destinationId: destinationId, @@ -164,6 +164,7 @@ class ConversionSegment { originalStartTime: originalSegment.start_time, originalEndTime: originalSegment.end_time }); + }); } @@ -309,16 +310,15 @@ class ConversionSegment { } return conn.tx(async t => { - // update the start time of the next segment - await t.none(sqlFile('conversionSegment/update_next_seg_start_to_curr_start.sql'), { + // delete segment passed in + await t.none(sqlFile('conversionSegment/delete_conversion_segment.sql'), { sourceId: sourceId, destinationId: destinationId, startTime: startTime, endTime: endTime }); - - // delete segment passed in - await t.none(sqlFile('conversionSegment/delete_conversion_segment.sql'), { + // update the start time of the next segment + await t.none(sqlFile('conversionSegment/update_next_seg_start_to_curr_start.sql'), { sourceId: sourceId, destinationId: destinationId, startTime: startTime, diff --git a/src/server/models/DaySegment.js b/src/server/models/DaySegment.js index 54e9e6598b..1bb2fd8e53 100644 --- a/src/server/models/DaySegment.js +++ b/src/server/models/DaySegment.js @@ -20,7 +20,7 @@ class DaySegment { constructor(id, dayId, startHour, endHour, slope, intercept, note) { this.id = id; this.dayId = dayId; - this.startHour = startHour; + this.startHour = startHour; this.endHour = endHour; this.slope = slope; this.intercept = intercept; @@ -52,8 +52,8 @@ class DaySegment { ); } - /** - * Returns the day segment associated the id. + /** + * Returns the day segment associated the id. * If the day segment doesn't exist then return null. * @param {*} id The day segment id. * @param {*} conn The connection to use. @@ -66,7 +66,7 @@ class DaySegment { return DaySegment.mapRow(row); } - /** + /** * Returns all day segments associated with the day id. * @param {*} dayId The day pattern id. * @param {*} conn The connection to use. @@ -200,7 +200,7 @@ class DaySegment { }); }); } - + /** * Returns a promise to update a daySegment in the database. * Note: This function only supports updates where the new start and/or end hour extends into the immediately adjacent segments. @@ -242,6 +242,24 @@ class DaySegment { }); } + /** + * Delete the day segment associated with the dayId, startHour, and endHour. + * This method does not update the previous or next segments. + * @param {*} dayId The dayId of the segment. + * @param {*} startHour The start hour of the segment to delete. + * @param {*} endHour The end hour of the segment to delete. + * @param {*} conn The connection to use. + * @param {*} startHour The start hour of the segment to be deleted. + * @param {*} endHour The end hour of the segment to be deleted. + */ + static async delete(dayId, startHour, endHour, conn) { + await conn.none(sqlFile('daySegment/delete_day_segment.sql'), { + dayId: dayId, + startHour: startHour, + endHour: endHour + }); + } + /** * Delete day segment after updating the end time of the previous segment to the end time of the deleted segment. * @param {*} dayId The id for the day segment to be deleted. @@ -305,4 +323,4 @@ class DaySegment { } } -module.exports = DaySegment; \ No newline at end of file +module.exports = DaySegment; diff --git a/src/server/models/Holiday.js b/src/server/models/Holiday.js new file mode 100644 index 0000000000..0d6a5c597b --- /dev/null +++ b/src/server/models/Holiday.js @@ -0,0 +1,118 @@ +/* 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 sqlFile = database.sqlFile; + +class Holiday{ + /** + * @param id Id of holiday + * @param name Name of the holiday + * @param startDate The day that a holiday takes place on. If a holiday extends into another day, only this day is used + * @param location location set by user + * @param note The holiday note + * @param type The holiday type + */ + constructor(id, name, startDate, location, note = '', type = 'custom') { + this.id = id; + this.name = name; + this.startDate = startDate; + this.location = location; + this.note = note; + this.type = type; + } + + /** + * Returns a promise to create the holidays table + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('holiday/create_holidays_table.sql')); + } + + /** + * Returns a promise to delete a holiday + * @param id the id of the holiday + * @param conn is the connection to use. + * @returns {Promise} + */ + static deleteHoliday(id, conn) { + return conn.none(sqlFile('holiday/delete_holiday.sql'), { id: id }); + } + + /** + * Returns a promise to get all of the holidays from the database + * @param conn is the connection to use. + * @returns {Promise.>} + */ + static async getAll(conn) { + const rows = await conn.any(sqlFile('holiday/get_all.sql')); + return rows.map(row => new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type)); + } + + /** + * Returns a promise to retrieve the holiday with the given id from the database. + * @param conn is the connection to use. + * @param id + * @returns {Promise.} + */ + static async getById(id, conn) { + const row = await conn.one(sqlFile('holiday/get_by_id.sql'), { id: id }); + return new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type); + } + + /** + * Returns a promise to get all of the holidays with the given location from the database + * @param conn is the connection to use. + * @param location + * @returns {Promise.>} + */ + static async getByLocation(location, conn) { + const rows = await conn.any(sqlFile('holiday/get_by_location.sql'), { location: location }); + return rows.map(row => new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type)); + } + + /** + * Returns id of inserted holiday + * @param conn is the connection to use. + * @returns id of inserted holiday + */ + async insert(conn) { + const holiday = this; + if (holiday.id !== undefined) { + throw new Error('Attempted to insert a holiday that already has an ID'); + } + const resp = await conn.one(sqlFile('holiday/insert_new_holiday.sql'), holiday); + this.id = resp.id; + } + /** + * Returns a promise to insert this holiday into the database, + * or update it if it already exists. + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + insertOrUpdate(conn) { + return conn.none( + sqlFile('holiday/insert_or_update_holiday.sql'), + this + ); + } + + /** + * Returns a promise to update a holiday + * @param id the id of the holiday to be updated + * @param name the new name + * @param startDate the new start date + * @param location the new location + * @param note the new note + * @param conn is the connection to use. + * @returns {Promise} + */ + static async updateHoliday(id, name, startDate, location, note, conn) { + return conn.none(sqlFile('holiday/update_holiday.sql'), { id: id, name: name, startDate: startDate, location: location, note: note }); + } +} + +module.exports = Holiday; diff --git a/src/server/models/HolidayGroupMember.js b/src/server/models/HolidayGroupMember.js new file mode 100644 index 0000000000..8154bf8848 --- /dev/null +++ b/src/server/models/HolidayGroupMember.js @@ -0,0 +1,81 @@ +/* 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 sqlFile = database.sqlFile; + +class HolidayGroupMember { + /** + * @param holidayInstanceGroupId id of HolidayInstanceGroup + * @param holidayInstanceId id of HolidayInstance + */ + constructor(holidayInstanceGroupId, holidayInstanceId) { + this.holidayInstanceGroupId = holidayInstanceGroupId; + this.holidayInstanceId = holidayInstanceId; + } + + /** + * Returns a promise to create the holiday_group_members table + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('holidayGroupMember/create_holiday_group_members_table.sql')); + } + + /** + * Returns a promise to get holidayGroup memeber and associated holiday instances, holidays, and day patterns with given holidayInstanceGroupId + * @param conn is the connection to use. + * @param holidayInstanceGroupId + * @returns FIXME: FIGURE OUT WHAT THIS RETURNS + */ + static async getByGroupId(holidayInstanceGroupId, conn) { + const rows = await conn.any(sqlFile('holidayGroupMember/get_by_group_id.sql'), { holidayInstanceGroupId: holidayInstanceGroupId }); + return rows; + } + + /** + * Returns a promise to get holiday dates associated with given holidayInstanceGroupId + * @param conn is the connection to use. + * @param holidayInstanceGroupId + * @returns FIXME: FIGURE OUT WHAT THIS RETURNS + */ + static async getHolidayDatesByGroupId(holidayInstanceGroupId, conn) { + const rows = await conn.any(sqlFile('holidayGroupMember/get_holiday_dates_by_group_id.sql'), { holidayInstanceGroupId: holidayInstanceGroupId }); + return rows; + } + + /** + * Returns a promise to insert this holidayGroupMember into the database + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + async insert(conn) { + const holidayGroupMember = this; + return await conn.none(sqlFile('holidayGroupMember/insert_new_holiday_group_member.sql'), holidayGroupMember); + } + + /** + * Returns a promise to delete holidayGroupMembers with given holidayInstanceGroupId + * @param holidayInstanceGroupId id of holidayInstanceGroup + * @param conn is the connection to use. + * @returns {Promise} + */ + static deleteByGroupId(holidayInstanceGroupId, conn) { + return conn.none(sqlFile('holidayGroupMember/delete_by_group_id.sql'), { holidayInstanceGroupId: holidayInstanceGroupId }); + } + + /** + * Returns a promise to delete holidayGroupMembers with given holidayInstanceGroupId and holidayInstanceId + * @param holidayInstanceGroupId id of holidayInstanceGroup + * @param holidayInstanceId id of holidayInstance + * @param conn is the connection to use. + * @returns {Promise} + */ + static deleteHolidayGroupMember(holidayInstanceGroupId, holidayInstanceId, conn) { + return conn.none(sqlFile('holidayGroupMember/delete_holiday_group_member.sql'), { holidayInstanceGroupId: holidayInstanceGroupId, holidayInstanceId: holidayInstanceId }); + } +} + +module.exports = HolidayGroupMember; \ No newline at end of file diff --git a/src/server/models/HolidayInstance.js b/src/server/models/HolidayInstance.js new file mode 100644 index 0000000000..304c788249 --- /dev/null +++ b/src/server/models/HolidayInstance.js @@ -0,0 +1,114 @@ +/* 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 sqlFile = database.sqlFile; + +class HolidayInstance { + /** + * @param id Id of holidayInstance + * @param name Name of the holidayInstance + * @param note The holidayInstance note + * @param holidayId id of holiday that instance is an instance of + * @param dayPatternId id of day pattern holidayInstance follows + */ + constructor(id, name, holidayId, dayPatternId, note = '') { + this.id = id; + this.name = name; + this.holidayId = holidayId; + this.dayPatternId = dayPatternId; + this.note = note; + } + + /** + * Returns a promise to create the holidayInstance table + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('holidayInstance/create_holiday_instance_table.sql')); + } + + /** + * Returns a promise to get all of the holidayInstances from the database, ordered by name + * @param conn is the connection to use. + * @returns {Promise.>} + */ + static async getAll(conn) { + const rows = await conn.any(sqlFile('holidayInstance/get_all.sql')); + return rows.map(row => new HolidayInstance(row.id, row.name, row.holiday_id, row.day_pattern_id, row.note)); + } + + /** + * Returns a promise to get all of the holidayInstances with the given holidayId from the database, ordered by name + * @param conn is the connection to use. + * @param holidayId + * @returns {Promise.>} + */ + static async getByHolidayId(holidayId, conn) { + const rows = await conn.any(sqlFile('holidayInstance/get_by_holiday_id.sql'), { holidayId: holidayId }); + return rows.map(row => new HolidayInstance(row.id, row.name, row.holiday_id, row.day_pattern_id, row.note)); + } + + /** + * Returns a promise to retrieve the holidayInstance with the given id from the database, ordered by name + * @param conn is the connection to use. + * @param id + * @returns {Promise.} + */ + static async getById(id, conn) { + const row = await conn.one(sqlFile('holidayInstance/get_by_id.sql'), { id: id }); + return new HolidayInstance(row.id, row.name, row.holiday_id, row.day_pattern_id, row.note); + } + + /** + * Returns a promise to get all holidayInstances with associated holidays and day patterns + * @param conn is the connection to use. + * @returns FIXME: FIGURE OUT WHAT THIS RETURNS + */ + static async getWithDetails(conn) { + const rows = await conn.any(sqlFile('holidayInstance/get_with_details.sql')); + return rows; + } + + /** + * Returns id of inserted holidayInstance + * @param conn is the connection to use. + * @returns id of inserted holidayInstance + */ + async insert(conn) { + const holidayInstance = this; + if (holidayInstance.id !== undefined) { + throw new Error('Attempted to insert a holidayInstance that already has an ID'); + } + const resp = await conn.one(sqlFile('holidayInstance/insert_new_holiday_instance.sql'), holidayInstance); + this.id = resp.id; + } + + /** + * Returns a promise to update a holidayInstance + * @param id the id of the holiday to be updated + * @param name the new name + * @param holidayId the new holidayId + * @param dayPatternId the new dayPatternId + * @param note the new note + * @param conn is the connection to use. + * @returns {Promise} + */ + static async updateHoliday(id, name, holidayId, dayPatternId, note, conn) { + return conn.none(sqlFile('holidayInstance/update_holiday_instance.sql'), { id: id, name: name, holidayId: holidayId, dayPatternId: dayPatternId, note: note }); + } + + /** + * Returns a promise to delete a holidayInstance + * @param id the id of the holidayInstance + * @param conn is the connection to use. + * @returns {Promise} + */ + static deleteHolidayInstance(id, conn) { + return conn.none(sqlFile('holidayInstance/delete_holiday_instance.sql'), { id: id }); + } +} + +module.exports = HolidayInstance; diff --git a/src/server/models/HolidayInstanceGroup.js b/src/server/models/HolidayInstanceGroup.js new file mode 100644 index 0000000000..32d5bfd6b7 --- /dev/null +++ b/src/server/models/HolidayInstanceGroup.js @@ -0,0 +1,87 @@ +/* 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 sqlFile = database.sqlFile; + +class HolidayInstanceGroup { + /** + * @param id Id of holidayInstanceGroup + * @param name Name of the holidayInstanceGroup + * @param note The holidayInstanceGroup note + */ + constructor(id, name, note = '') { + this.id = id; + this.name = name; + this.note = note; + } + + /** + * Returns a promise to create the holidayInstanceGroup table + * @param conn is the connection to use. + * @returns {Promise.<>} + */ + static createTable(conn) { + return conn.none(sqlFile('holidayInstanceGroup/create_holiday_instance_group_table.sql')); + } + + /** + * Returns a promise to get all of the holidayInstanceGroups from the database + * @param conn is the connection to use. + * @returns {Promise.>} + */ + static async getAll(conn) { + const rows = await conn.any(sqlFile('holidayInstanceGroup/get_all.sql')); + return rows.map(row => new HolidayInstanceGroup(row.id, row.name, row.note)); + } + + /** + * Returns a promise to retrieve the holidayInstanceGroup with the given id from the database. + * @param conn is the connection to use. + * @param id + * @returns {Promise.} + */ + static async getById(id, conn) { + const row = await conn.one(sqlFile('holidayInstanceGroup/get_by_id.sql'), { id: id }); + return new HolidayInstanceGroup(row.id, row.name, row.note); + } + + /** + * Returns id of inserted holidayInstanceGroup + * @param conn is the connection to use. + * @returns id of inserted holidayInstanceGroup + */ + async insert(conn) { + const holidayInstanceGroup = this; + if (holidayInstanceGroup.id !== undefined) { + throw new Error('Attempted to insert a holidayInstanceGroup that already has an ID'); + } + const resp = await conn.one(sqlFile('holidayInstanceGroup/insert_new_holiday_instance_group.sql'), holidayInstanceGroup); + this.id = resp.id; + } + + /** + * Returns a promise to update a holiday + * @param id the id of the holiday to be updated + * @param name the new name + * @param note the new note + * @param conn is the connection to use. + * @returns {Promise} + */ + static async updateHolidayInstanceGroup(id, name, note, conn) { + return conn.none(sqlFile('holidayInstanceGroup/update_holiday_instance_group.sql'), { id: id, name: name, note: note }); + } + + /** + * Returns a promise to delete a holidayInstanceGroup + * @param id the id of the holidayInstanceGroup + * @param conn is the connection to use. + * @returns {Promise} + */ + static deleteHolidayInstanceGroup(id, conn) { + return conn.none(sqlFile('holidayInstanceGroup/delete_holiday_instance_group.sql'), { id: id }); + } +} + +module.exports = HolidayInstanceGroup; \ No newline at end of file diff --git a/src/server/models/Week.js b/src/server/models/Week.js index 7301868c6c..0e759d8af2 100644 --- a/src/server/models/Week.js +++ b/src/server/models/Week.js @@ -1,7 +1,7 @@ /* 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 sqlFile = database.sqlFile; @@ -17,8 +17,21 @@ class Week { * @param {*} thursday The id for thursday's day_pattern. * @param {*} friday The id for friday's day_pattern. * @param {*} saturday The id for saturday's day_pattern. + * @param {*} holidayInstanceGroupId The optional holiday instance group id. */ - constructor(id, name, note, sunday, monday, tuesday, wednesday, thursday, friday, saturday) { + constructor( + id, + name, + note, + sunday, + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + holidayInstanceGroupId + ) { this.id = id; this.name = name; this.note = note; @@ -29,6 +42,7 @@ class Week { this.thursday = thursday; this.friday = friday; this.saturday = saturday; + this.holidayInstanceGroupId = holidayInstanceGroupId ?? null; } /** @@ -47,16 +61,17 @@ class Week { */ static mapRow(row) { return new Week( - row.id, - row.name, - row.note, - row.sunday, - row.monday, - row.tuesday, - row.wednesday, - row.thursday, - row.friday, - row.saturday + row.id, + row.name, + row.note, + row.sunday, + row.monday, + row.tuesday, + row.wednesday, + row.thursday, + row.friday, + row.saturday, + row.holiday_instance_group_id ?? null ); } @@ -70,7 +85,7 @@ class Week { return rows.map(Week.mapRow); } - /** + /** * Get the week associated with id. If the week doesn't exist then return null. * @param {*} id The week id. * @param {*} conn The connection to use. @@ -113,6 +128,6 @@ class Week { id: id }); } -} +} -module.exports = Week; \ No newline at end of file +module.exports = Week; diff --git a/src/server/models/database.js b/src/server/models/database.js index d46e91a188..8e2c30105e 100644 --- a/src/server/models/database.js +++ b/src/server/models/database.js @@ -100,6 +100,10 @@ async function createSchema(conn) { await Unit.createTable(conn); await Day.createTable(conn); await DaySegment.createTable(conn); + await conn.none(sqlFile('holiday/create_holidays_table.sql')); + await conn.none(sqlFile('holidayInstance/create_holiday_instance_table.sql')); + await conn.none(sqlFile('holidayInstanceGroup/create_holiday_instance_group_table.sql')); + await conn.none(sqlFile('holidayGroupMember/create_holiday_group_members_table.sql')); await Week.createTable(conn); await Conversion.createTable(conn); await ConversionSegment.createTable(conn); diff --git a/src/server/routes/conversionSegments.js b/src/server/routes/conversionSegments.js index 63346911dd..482a0fc936 100644 --- a/src/server/routes/conversionSegments.js +++ b/src/server/routes/conversionSegments.js @@ -238,8 +238,8 @@ router.post('/splitEarlier', adminAuthMiddleware('split earlier conversion segme }, newWeekPatternsId: { oneOf: [ - {type: 'integer', minimum: 0}, - {type: 'null'} + { type: 'integer' }, + { type: 'null' } ] }, newSlope: { @@ -271,7 +271,7 @@ router.post('/splitEarlier', adminAuthMiddleware('split earlier conversion segme await ConversionSegment.splitEarlier( req.body.sourceId, req.body.destinationId, - req.body.newWeekPatternsId, + req.body.newWeekPatternsId === -99 ? null : req.body.newWeekPatternsId, req.body.newSlope, req.body.newIntercept, req.body.newNote, @@ -323,8 +323,8 @@ router.post('/splitLater', adminAuthMiddleware('split later conversion segment') }, newWeekPatternsId: { oneOf: [ - {type: 'integer', minimum: 0}, - {type: 'null'} + { type: 'integer' }, + { type: 'null' } ] }, newSlope: { @@ -356,7 +356,7 @@ router.post('/splitLater', adminAuthMiddleware('split later conversion segment') await ConversionSegment.splitLater( req.body.sourceId, req.body.destinationId, - req.body.newWeekPatternsId, + req.body.newWeekPatternsId === -99 ? null : req.body.newWeekPatternsId, req.body.newSlope, req.body.newIntercept, req.body.newNote, @@ -404,8 +404,8 @@ router.post('/edit', adminAuthMiddleware('edit conversion segment'), async (req, }, weekPatternsId: { oneOf: [ - {type: 'integer', minimum: 0}, - {type: 'null'} + { type: 'integer' }, + { type: 'null' } ] }, slope: { @@ -446,7 +446,7 @@ router.post('/edit', adminAuthMiddleware('edit conversion segment'), async (req, const updatedConversionSegment = new ConversionSegment( req.body.sourceId, req.body.destinationId, - req.body.weekPatternsId, + req.body.weekPatternsId === -99 ? null : req.body.weekPatternsId, req.body.slope, req.body.intercept, momentToIsoOrInfinity(req.body.startTime), diff --git a/src/server/routes/conversions.js b/src/server/routes/conversions.js index 08fec8de47..668e1cabc0 100644 --- a/src/server/routes/conversions.js +++ b/src/server/routes/conversions.js @@ -144,7 +144,7 @@ router.post('/addConversion', adminAuthMiddleware('add conversions'), async (req req.body.note ); await newConversion.insert( - req.body.weekPatternsId, + req.body.weekPatternsId === -99 ? null : req.body.weekPatternsId, req.body.slope, req.body.intercept, req.body.segmentNote, diff --git a/src/server/routes/daySegments.js b/src/server/routes/daySegments.js index 0ff7d14018..e1a4d12fd9 100644 --- a/src/server/routes/daySegments.js +++ b/src/server/routes/daySegments.js @@ -385,6 +385,61 @@ router.post('/edit', adminAuthMiddleware('edit day segment'), async (req, res) = } }); +/** + * POST delete day segment. + * @param {integer} dayId The day id for the day segment to be deleted. + * @param {number} startHour The start hour for the day segment to be deleted. + * @param {number} endHour The end hour for the day segment to be deleted. + */ +router.post('/delete', adminAuthMiddleware('delete day segment'), async (req, res) => { + const validDaySegment = { + type: 'object', + maxProperties: 3, + required: ['dayId', 'startHour', 'endHour'], + properties: { + dayId: { + type: 'integer', + minimum: 0 + }, + startHour: { + type: 'number', + minimum: 0, + maximum: 23 + }, + endHour: { + type: 'number', + minimum: 1, + maximum: 24 + } + } + }; + + // Ensure day segment object is valid + const validatorResult = validate(req.body, validDaySegment); + if (!validatorResult.valid) { + const errMsg = `Got request to delete a day segment with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + // Don't worry about checking if the day segment already exists + // Just try to delete it to save the extra database call, since the database will return an error anyway if the row does not exist + await DaySegment.delete( + req.body.dayId, + req.body.startHour, + req.body.endHour, + conn + ); + success(res, 'Successfully deleted day segment'); + } catch (err) { + const errMsg = `Error while deleting day segment with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + /** * POST delete day segment after updating the end time of the previous segment to the end time of the deleted segment. * @param {integer} dayId The day id for the day segment to be deleted. diff --git a/src/server/routes/holidayGroupMembers.js b/src/server/routes/holidayGroupMembers.js new file mode 100644 index 0000000000..2cd7ce78de --- /dev/null +++ b/src/server/routes/holidayGroupMembers.js @@ -0,0 +1,238 @@ +/* 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 express = require('express'); +const { log } = require('../log'); +const { getConnection } = require('../db'); +const HolidayGroupMember = require('../models/HolidayGroupMember'); +const { success, failure } = require('./response'); +const validate = require('jsonschema').validate; +const { adminAuthMiddleware } = require('./authenticator'); + +const router = express.Router(); + +function formatHolidayGroupMemberForResponse(item) { + return { + holidayInstanceGroupId: item.holiday_instance_group_id, + holidayInstanceId: item.holiday_instance_id, + holidayInstanceName: item.holiday_instance_name, + holidayId: item.holiday_id, + dayPatternId: item.day_pattern_id, + holidayName: item.holiday_name, + startDate: item.start_date, + location: item.location, + dayPatternName: item.day_pattern_name + }; +} + +function formatHolidayDateCountForResponse(item) { + return { + startDate: item.start_date, + holidayCount: Number(item.holiday_count) + }; +} + +/** + * Route for getting holiday group members, with joined holiday instance, holiday, and day pattern details, + * for a holiday instance group + * + * Route params: + * - holidayInstanceGroupId: numeric holiday instance group id + */ +router.get('/group/:holidayInstanceGroupId', adminAuthMiddleware('get holiday group members by group id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['holidayInstanceGroupId'], + properties: { + holidayInstanceGroupId: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve holiday group members with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const rows = await HolidayGroupMember.getByGroupId(req.params.holidayInstanceGroupId, conn); + res.json(rows.map(formatHolidayGroupMemberForResponse)); + } catch (err) { + log.error(`Error while performing GET holiday group members query: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * Route for getting resolved holiday dates for a holiday instance group + * + * Route params: + * - holidayInstanceGroupId: numeric holiday instance group id + */ +router.get('/group/:holidayInstanceGroupId/holidayDates', adminAuthMiddleware('get holiday dates by holiday instance group id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['holidayInstanceGroupId'], + properties: { + holidayInstanceGroupId: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve holiday dates with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const rows = await HolidayGroupMember.getHolidayDatesByGroupId(req.params.holidayInstanceGroupId, conn); + res.json(rows.map(formatHolidayDateCountForResponse)); + } catch (err) { + log.error(`Error while performing GET holiday dates by group query: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * Route for adding a holiday instance to a holiday instance group + * + * Request body: + * - holidayInstanceGroupId: group id to add the instance to + * - holidayInstanceId: holiday instance id to add + */ +router.post('/add', adminAuthMiddleware('add holiday group member'), async (req, res) => { + const validHolidayGroupMember = { + type: 'object', + maxProperties: 2, + required: ['holidayInstanceGroupId', 'holidayInstanceId'], + properties: { + holidayInstanceGroupId: { + type: 'integer', + minimum: 0 + }, + holidayInstanceId: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHolidayGroupMember); + if (!validatorResult.valid) { + const errMsg = `Got request to add a holiday group member with invalid holiday group member data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const newHolidayGroupMember = new HolidayGroupMember( + req.body.holidayInstanceGroupId, + req.body.holidayInstanceId + ); + await newHolidayGroupMember.insert(conn); + success(res, 'Successfully added holiday group member'); + } catch (err) { + const errMsg = `Error adding holiday group member with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for removing one holiday instance from a holiday instance group + * + * Request body: + * - holidayInstanceGroupId: group id to remove the instance from + * - holidayInstanceId: holiday instance id to remove + */ +router.post('/delete', adminAuthMiddleware('delete holiday group member'), async (req, res) => { + const validHolidayGroupMember = { + type: 'object', + maxProperties: 2, + required: ['holidayInstanceGroupId', 'holidayInstanceId'], + properties: { + holidayInstanceGroupId: { + type: 'integer', + minimum: 0 + }, + holidayInstanceId: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHolidayGroupMember); + if (!validatorResult.valid) { + const errMsg = `Got request to delete a holiday group member with invalid holiday group member data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayGroupMember.deleteHolidayGroupMember( + req.body.holidayInstanceGroupId, + req.body.holidayInstanceId, + conn + ); + success(res, 'Successfully deleted holiday group member'); + } catch (err) { + const errMsg = `Error deleting holiday group member with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for removing all holiday instances from a holiday instance group + * + * Request body: + * - holidayInstanceGroupId: group id whose members should be removed + */ +router.post('/deleteByGroup', adminAuthMiddleware('delete holiday group members by group'), async (req, res) => { + const validHolidayGroupMembersDeleteByGroup = { + type: 'object', + maxProperties: 1, + required: ['holidayInstanceGroupId'], + properties: { + holidayInstanceGroupId: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHolidayGroupMembersDeleteByGroup); + if (!validatorResult.valid) { + const errMsg = `Got request to delete holiday group members by group with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayGroupMember.deleteByGroupId(req.body.holidayInstanceGroupId, conn); + success(res, 'Successfully deleted holiday group members for group'); + } catch (err) { + const errMsg = `Error deleting holiday group members by group with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +module.exports = router; diff --git a/src/server/routes/holidayInstanceGroups.js b/src/server/routes/holidayInstanceGroups.js new file mode 100644 index 0000000000..24158f2267 --- /dev/null +++ b/src/server/routes/holidayInstanceGroups.js @@ -0,0 +1,215 @@ +/* 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 express = require('express'); +const { log } = require('../log'); +const { getConnection } = require('../db'); +const HolidayInstanceGroup = require('../models/HolidayInstanceGroup'); +const { success, failure } = require('./response'); +const validate = require('jsonschema').validate; +const { adminAuthMiddleware } = require('./authenticator'); + +const router = express.Router(); + +function formatHolidayInstanceGroupForResponse(item) { + return { + id: item.id, + name: item.name, + note: item.note + }; +} + +/** + * Route for getting all holiday instance groups. + * + * Response: 200 OK with an array of holiday instance groups + */ +router.get('/', adminAuthMiddleware('get all holiday instance groups'), async (req, res) => { + const conn = getConnection(); + try { + const rows = await HolidayInstanceGroup.getAll(conn); + res.json(rows.map(formatHolidayInstanceGroupForResponse)); + } catch (err) { + log.error(`Error while performing GET all holiday instance groups query: ${err}`); + res.sendStatus(500); + } +}); + +/** + * Route for getting one holiday instance group by id. + * + * Route params: + * - id: numeric holiday instance group id + */ +router.get('/:id', adminAuthMiddleware('get holiday instance group by id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['id'], + properties: { + id: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve a holiday instance group by id with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const row = await HolidayInstanceGroup.getById(req.params.id, conn); + res.json(formatHolidayInstanceGroupForResponse(row)); + } catch (err) { + log.error(`Error while performing GET holiday instance group by id query: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * Route for adding a new holiday instance group. + * + * Request body: + * - name: non-empty holiday instance group name + * - note: optional string or null notes for the group + */ +router.post('/addHolidayInstanceGroup', adminAuthMiddleware('add holiday instance group'), async (req, res) => { + const validHolidayInstanceGroup = { + type: 'object', + maxProperties: 2, + required: ['name'], + properties: { + name: { + type: 'string', + minLength: 1 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstanceGroup); + if (!validatorResult.valid) { + const errMsg = `Got request to add a holiday instance group with invalid holiday instance group data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const newHolidayInstanceGroup = new HolidayInstanceGroup( + undefined, + req.body.name, + req.body.note + ); + await newHolidayInstanceGroup.insert(conn); + res.json(formatHolidayInstanceGroupForResponse(newHolidayInstanceGroup)); + } catch (err) { + const errMsg = `Error adding new holiday instance group with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for editing an existing holiday instance group. + * + * Request body: + * - id: holiday instance group id to update + * - name: non-empty replacement name + * - note: optional string or null replacement notes + */ +router.post('/edit', adminAuthMiddleware('edit holiday instance group'), async (req, res) => { + const validHolidayInstanceGroup = { + type: 'object', + maxProperties: 3, + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + minimum: 0 + }, + name: { + type: 'string', + minLength: 1 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstanceGroup); + if (!validatorResult.valid) { + const errMsg = `Got request to edit a holiday instance group with invalid holiday instance group data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayInstanceGroup.updateHolidayInstanceGroup( + req.body.id, + req.body.name, + req.body.note, + conn + ); + success(res, 'Successfully edited holiday instance group'); + } catch (err) { + const errMsg = `Error while editing a holiday instance group with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for deleting a holiday instance group. + * + * Request body: + * - id: holiday instance group id to delete + */ +router.post('/delete', adminAuthMiddleware('delete holiday instance group'), async (req, res) => { + const validHolidayInstanceGroup = { + type: 'object', + maxProperties: 1, + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstanceGroup); + if (!validatorResult.valid) { + const errMsg = `Got request to delete a holiday instance group with invalid holiday instance group data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayInstanceGroup.deleteHolidayInstanceGroup(req.body.id, conn); + success(res, 'Successfully deleted holiday instance group'); + } catch (err) { + const errMsg = `Error while deleting a holiday instance group with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +module.exports = router; diff --git a/src/server/routes/holidayInstances.js b/src/server/routes/holidayInstances.js new file mode 100644 index 0000000000..76739a5f66 --- /dev/null +++ b/src/server/routes/holidayInstances.js @@ -0,0 +1,303 @@ +/* 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 express = require('express'); +const { log } = require('../log'); +const { getConnection } = require('../db'); +const HolidayInstance = require('../models/HolidayInstance'); +const { success, failure } = require('./response'); +const validate = require('jsonschema').validate; +const { adminAuthMiddleware } = require('./authenticator'); + +const router = express.Router(); + +/** + * Formats a holiday instance for response + * @param {Object} item - The holiday instance to format + * @returns {Object} The formatted holiday instance + */ +function formatHolidayInstanceForResponse(item) { + return { + id: item.id, + name: item.name, + holidayId: item.holidayId, + dayPatternId: item.dayPatternId, + note: item.note + }; +} + +/** + * Formats a holiday instance with details for response + * @param {Object} item - The holiday instance to format + * @returns {Object} The formatted holiday instance with details + */ +function formatHolidayInstanceDetailsForResponse(item) { + return { + id: item.id, + name: item.name, + note: item.note, + holidayId: item.holiday_id, + dayPatternId: item.day_pattern_id, + holidayName: item.holiday_name, + startDate: item.start_date, + location: item.location, + dayPatternName: item.day_pattern_name + }; +} + +/** + * Route for getting all holiday instances ordered by name + */ +router.get('/', adminAuthMiddleware('get all holiday instances'), async (req, res) => { + const conn = getConnection(); + try { + const rows = await HolidayInstance.getAll(conn); + res.json(rows.map(formatHolidayInstanceForResponse)); + } catch (err) { + log.error(`Error while performing GET all holiday instances query: ${err}`); + res.sendStatus(500); + } +}); + +/** + * Route for getting all holiday instances with joined holiday and day pattern details + */ +router.get('/withDetails', adminAuthMiddleware('get holiday instances with details'), async (req, res) => { + const conn = getConnection(); + try { + const rows = await HolidayInstance.getWithDetails(conn); + res.json(rows.map(formatHolidayInstanceDetailsForResponse)); + } catch (err) { + log.error(`Error while performing GET holiday instances with details query: ${err}`); + res.sendStatus(500); + } +}); + +/** + * Route for getting holiday instances for a given holiday id + * + * Route params: + * - holidayId: numeric holiday id + */ +router.get('/holiday/:holidayId', adminAuthMiddleware('get holiday instances by holiday id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['holidayId'], + properties: { + holidayId: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve holiday instances by holiday id with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const rows = await HolidayInstance.getByHolidayId(req.params.holidayId, conn); + res.json(rows.map(formatHolidayInstanceForResponse)); + } catch (err) { + log.error(`Error while performing GET holiday instances by holiday id query: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * Route for getting one holiday instance by id + * + * Route params: + * - holidayInstanceId: numeric holiday instance id + */ +router.get('/:holidayInstanceId', adminAuthMiddleware('get holiday instance by id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['holidayInstanceId'], + properties: { + holidayInstanceId: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve a holiday instance by id with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const row = await HolidayInstance.getById(req.params.holidayInstanceId, conn); + res.json(formatHolidayInstanceForResponse(row)); + } catch (err) { + log.error(`Error while performing GET holiday instance by id query: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * Route for adding a new holiday instance + */ +router.post('/addHolidayInstance', adminAuthMiddleware('add holiday instance'), async (req, res) => { + const validHolidayInstance = { + type: 'object', + maxProperties: 4, + required: ['name', 'holidayId', 'dayPatternId'], + properties: { + name: { + type: 'string', + minLength: 1 + }, + holidayId: { + type: 'integer', + minimum: 0 + }, + dayPatternId: { + type: 'integer', + minimum: 0 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstance); + if (!validatorResult.valid) { + const errMsg = `Got request to add a holiday instance with invalid holiday instance data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const newHolidayInstance = new HolidayInstance( + undefined, + req.body.name, + req.body.holidayId, + req.body.dayPatternId, + req.body.note + ); + await newHolidayInstance.insert(conn); + res.json(formatHolidayInstanceForResponse(newHolidayInstance)); + } catch (err) { + const errMsg = `Error adding new holiday instance with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for editing an existing holiday instance + * + * Request body: + * - id: numeric holiday instance id + */ +router.post('/edit', adminAuthMiddleware('edit holiday instance'), async (req, res) => { + const validHolidayInstance = { + type: 'object', + maxProperties: 5, + required: ['id', 'name', 'holidayId', 'dayPatternId'], + properties: { + id: { + type: 'integer', + minimum: 0 + }, + name: { + type: 'string', + minLength: 1 + }, + holidayId: { + type: 'integer', + minimum: 0 + }, + dayPatternId: { + type: 'integer', + minimum: 0 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstance); + if (!validatorResult.valid) { + const errMsg = `Got request to edit a holiday instance with invalid holiday instance data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayInstance.updateHoliday( + req.body.id, + req.body.name, + req.body.holidayId, + req.body.dayPatternId, + req.body.note, + conn + ); + success(res, 'Successfully edited holiday instance'); + } catch (err) { + const errMsg = `Error while editing a holiday instance with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for deleting a holiday instance by id + * + * Request body: + * - id: numeric holiday instance id + */ +router.post('/delete', adminAuthMiddleware('delete holiday instance'), async (req, res) => { + const validHolidayInstance = { + type: 'object', + maxProperties: 1, + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHolidayInstance); + if (!validatorResult.valid) { + const errMsg = `Got request to delete a holiday instance with invalid holiday instance data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await HolidayInstance.deleteHolidayInstance(req.body.id, conn); + success(res, 'Successfully deleted holiday instance'); + } catch (err) { + const errMsg = `Error while deleting a holiday instance with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +module.exports = router; diff --git a/src/server/routes/holidays.js b/src/server/routes/holidays.js new file mode 100644 index 0000000000..0c185f4a83 --- /dev/null +++ b/src/server/routes/holidays.js @@ -0,0 +1,418 @@ +/* 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 express = require('express'); +const { log } = require('../log'); +const { getConnection } = require('../db'); +const Holiday = require('../models/Holiday'); +const DateHolidays = require('date-holidays'); +const { success, failure } = require('./response'); +const validate = require('jsonschema').validate; +const { adminAuthMiddleware } = require('./authenticator'); + +const router = express.Router(); + +const Holidays = DateHolidays.default || DateHolidays; + +/** + * Creates a standard location string for holiday table. + * Example: US, US-CA, US-CA-LA + * @param country country code + * @param state state/provence code + * @param region regional code + * @returns standardized location code. + */ +function buildLocationCode(country, state = '', region = ''){ + return [country, state, region].filter(Boolean).join('-'); +} + +/** + * Converts the location map from date-holidays into dropdown options. + * @param {Object.} locations Location codes and names. + * @returns {Array.<{code: string, name: string}>} Sorted location options. + */ +function formatLocationOptions(locations) { + return Object.entries(locations || {}) + .map(([code, name]) => ({ code, name })) + .sort((first, second) => first.name.localeCompare(second.name)); +} + +/** + * Maps imported Holidays to Holiday object + * @param item item holiday returned by date-holiday + * @param country country code + * @param state state/provence code + * @param region regional code + * @returns {Holiday} object ready for the DB. + */ +function mapImportedHoliday(item,country,state='',region=''){ + const name = String(item.name).trim(); + const startDate = String(item.date).slice(0,10); + const location = buildLocationCode(country,state,region); + const type = String(item.type).trim(); + + return new Holiday( + undefined, + name, + startDate, + location, + null, + type + ); +} + +function formatHolidayForResponse(item) { + let startDate = item.startDate; + + if (startDate instanceof Date) { + startDate = startDate.toISOString(); + } + + return { + id: item.id, + name: item.name, + startDate: String(startDate).slice(0, 10), + location: item.location, + type: item.type, + note: item.note + }; +} + +/** + * Route for getting all holidays. + * + * Error response: 500 when the database query fails. + */ +router.get('/', adminAuthMiddleware('get all holidays'), async (req, res) => { + const conn = getConnection(); + try { + const rows = await Holiday.getAll(conn); + res.json(rows.map(formatHolidayForResponse)); + } catch (err) { + const errMsg = `Error while performing GET all holidays query: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } +}); + +/** + * Route for getting holiday countries, states and regions. + */ +router.get('/locations', adminAuthMiddleware('get holiday locations'), async (req, res) => { + const validQuery = { + type: 'object', + properties: { + country: { + type: 'string' + }, + state: { + type: 'string' + } + } + }; + + const validatorResult = validate(req.query, validQuery); + + if (!validatorResult.valid) { + const errMsg = `Got request for holiday locations with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + try { + const holidays = new Holidays(); + const country = req.query.country; + const state = req.query.state; + + const countries = formatLocationOptions( + holidays.getCountries('en') + ); + + let states = []; + let regions = []; + + if (country) { + states = formatLocationOptions( + holidays.getStates(country, 'en') + ); + } + + if (country && state) { + regions = formatLocationOptions( + holidays.getRegions(country, state, 'en') + ); + } + + res.json({ + countries, + states, + regions + }); + } catch (err) { + const errMsg = `Error while getting holiday locations: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for refreshing holidays for a location and year. + */ +router.post('/refresh', adminAuthMiddleware('refresh holidays'), async (req, res) => { + const validRequest = { + type: 'object', + maxProperties: 4, + required: ['country', 'year'], + properties: { + country: { + type: 'string', + minLength: 1 + }, + state: { + type: 'string' + }, + region: { + type: 'string' + }, + year: { + type: 'integer' + } + } + }; + + const validatorResult = validate(req.body, validRequest); + + if (!validatorResult.valid) { + const errMsg = `Got request to refresh holidays with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const country = req.body.country; + const state = req.body.state || ''; + const region = req.body.region || ''; + const year = req.body.year; + + try { + const dateHolidays = new Holidays( + country, + state || undefined, + region || undefined + ); + + const importedHolidays = dateHolidays + .getHolidays(year, 'en') + .map(item => mapImportedHoliday( + item, + country, + state, + region + )); + + const conn = getConnection(); + + await conn.tx(async transaction => { + for (const holiday of importedHolidays) { + await holiday.insertOrUpdate(transaction); + } + }); + + res.json({ + location: buildLocationCode(country, state, region), + year, + total: importedHolidays.length + }); + } catch (err) { + const errMsg = `Error while refreshing holidays: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for getting one holiday by id. + * + * Route params: + * - holidayId: numeric holiday id. + */ +router.get('/:holidayId', adminAuthMiddleware('get holiday by id'), async (req, res) => { + const validParams = { + type: 'object', + maxProperties: 1, + required: ['holidayId'], + properties: { + holidayId: { + type: 'string', + pattern: '^\\d+$' + } + } + }; + + const validatorResult = validate(req.params, validParams); + if (!validatorResult.valid) { + const errMsg = `Got request to retrieve a holiday by id with invalid data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const row = await Holiday.getById(req.params.holidayId, conn); + res.json(formatHolidayForResponse(row)); + } catch (err) { + const errMsg = `Error while performing GET holiday by id query: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for adding a new holiday. + */ +router.post('/addHoliday', adminAuthMiddleware('add holiday'), async (req, res) => { + const validHoliday = { + type: 'object', + maxProperties: 4, + required: ['name', 'startDate', 'location'], + properties: { + name: { + type: 'string', + minLength: 1 + }, + startDate: { + type: 'string', + pattern: '^\\d{4}-\\d{2}-\\d{2}$' + }, + location: { + type: 'string', + minLength: 1 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHoliday); + if (!validatorResult.valid) { + const errMsg = `Got request to add a holiday with invalid holiday data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + const newHoliday = new Holiday( + undefined, + req.body.name, + req.body.startDate, + req.body.location, + req.body.note + ); + await newHoliday.insert(conn); + res.json(formatHolidayForResponse(newHoliday)); + } catch (err) { + const errMsg = `Error adding new holiday with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for editing an existing holiday. + */ +router.post('/edit', adminAuthMiddleware('edit holiday'), async (req, res) => { + const validHoliday = { + type: 'object', + maxProperties: 5, + required: ['id', 'name', 'startDate', 'location'], + properties: { + id: { + type: 'integer', + minimum: 0 + }, + name: { + type: 'string', + minLength: 1 + }, + startDate: { + type: 'string', + pattern: '^\\d{4}-\\d{2}-\\d{2}$' + }, + location: { + type: 'string', + minLength: 1 + }, + note: { + oneOf: [ + { type: 'string' }, + { type: 'null' } + ] + } + } + }; + + const validatorResult = validate(req.body, validHoliday); + if (!validatorResult.valid) { + const errMsg = `Got request to edit a holiday with invalid holiday data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await Holiday.updateHoliday( + req.body.id, + req.body.name, + req.body.startDate, + req.body.location, + req.body.note, + conn + ); + success(res, 'Successfully edited holiday'); + } catch (err) { + const errMsg = `Error while editing a holiday with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +/** + * Route for deleting a holiday by id. + */ +router.post('/delete', adminAuthMiddleware('delete holiday'), async (req, res) => { + const validHoliday = { + type: 'object', + maxProperties: 1, + required: ['id'], + properties: { + id: { + type: 'integer', + minimum: 0 + } + } + }; + + const validatorResult = validate(req.body, validHoliday); + if (!validatorResult.valid) { + const errMsg = `Got request to delete a holiday with invalid holiday data, error(s): ${validatorResult.errors}`; + log.warn(errMsg); + failure(res, 400, errMsg); + } else { + const conn = getConnection(); + try { + await Holiday.deleteHoliday(req.body.id, conn); + success(res, 'Successfully deleted holiday'); + } catch (err) { + const errMsg = `Error while deleting a holiday with error(s): ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); + } + } +}); + +module.exports = router; diff --git a/src/server/routes/weeks.js b/src/server/routes/weeks.js index 9a863e9a42..912f2fb206 100644 --- a/src/server/routes/weeks.js +++ b/src/server/routes/weeks.js @@ -23,7 +23,11 @@ function formatWeekForResponse(item) { wednesday: item.wednesday, thursday: item.thursday, friday: item.friday, - saturday: item.saturday + saturday: item.saturday, + holidayInstanceGroupId: + item.holidayInstanceGroupId ?? + item.holiday_instance_group_id ?? + null }; } @@ -83,11 +87,12 @@ router.get('/:id', adminAuthMiddleware('get week by id'), async(req, res) => { * @param {integer} thursday The id for the day pattern used for thursday. * @param {integer} friday The id for the day pattern used for friday. * @param {integer} saturday The id for the day pattern used for saturday. + * @param {integer|null} holidayInstanceGroupId The optional holiday rate group id. */ router.post('/addWeek', adminAuthMiddleware('add week'), async (req, res) => { const validWeek= { type: 'object', - maxProperties: 9, + maxProperties: 10, required: ['name', 'sunday','monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], properties: { name: { @@ -127,6 +132,15 @@ router.post('/addWeek', adminAuthMiddleware('add week'), async (req, res) => { saturday: { type: 'integer', minimum: 0 + }, + holidayInstanceGroupId: { + oneOf: [ + { + type: 'integer', + minimum: 0 + }, + { type: 'null' } + ] } } }; @@ -149,7 +163,8 @@ router.post('/addWeek', adminAuthMiddleware('add week'), async (req, res) => { req.body.wednesday, req.body.thursday, req.body.friday, - req.body.saturday + req.body.saturday, + req.body.holidayInstanceGroupId ?? null ); await newWeek.insert(conn); success(res, `Successfully inserted week`); @@ -173,11 +188,12 @@ router.post('/addWeek', adminAuthMiddleware('add week'), async (req, res) => { * @param {integer} thursday The new id for the day pattern used for thursday. * @param {integer} friday The new id for the day pattern used for friday. * @param {integer} saturday The new id for the day pattern used for saturday. + * @param {integer|null} holidayInstanceGroupId The optional holiday rate group id. */ router.post('/edit', adminAuthMiddleware('edit week'), async (req, res) => { const validWeek = { type: 'object', - maxProperties: 10, + maxProperties: 11, required: ['id', 'name', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], properties: { id: { @@ -221,6 +237,15 @@ router.post('/edit', adminAuthMiddleware('edit week'), async (req, res) => { saturday: { type: 'integer', minimum: 0 + }, + holidayInstanceGroupId: { + oneOf: [ + { + type: 'integer', + minimum: 0 + }, + { type: 'null' } + ] } } }; @@ -243,7 +268,8 @@ router.post('/edit', adminAuthMiddleware('edit week'), async (req, res) => { req.body.wednesday, req.body.thursday, req.body.friday, - req.body.saturday + req.body.saturday, + req.body.holidayInstanceGroupId ?? null ); await updatedWeek.update(conn); success(res, `Successfully edited week`); @@ -296,4 +322,4 @@ router.post('/delete', adminAuthMiddleware('delete week'), async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/server/sql/daySegment/update_next_seg_start_to_curr_start.sql b/src/server/sql/daySegment/update_next_seg_start_to_curr_start.sql index 64cd4b37e7..3bd5c674b8 100644 --- a/src/server/sql/daySegment/update_next_seg_start_to_curr_start.sql +++ b/src/server/sql/daySegment/update_next_seg_start_to_curr_start.sql @@ -1,7 +1,7 @@ /* 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 day_segments SET start_hour = ${startHour} - WHERE day_id = ${dayId} AND start_hour = ${endHour}; \ No newline at end of file + WHERE day_id = ${dayId} AND start_hour = ${endHour}; diff --git a/src/server/sql/daySegment/update_prev_seg_end_to_curr_end.sql b/src/server/sql/daySegment/update_prev_seg_end_to_curr_end.sql index 67fe9312ba..7c63e60ab6 100644 --- a/src/server/sql/daySegment/update_prev_seg_end_to_curr_end.sql +++ b/src/server/sql/daySegment/update_prev_seg_end_to_curr_end.sql @@ -1,7 +1,7 @@ /* 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 day_segments SET end_hour = ${endHour} - WHERE day_id = ${dayId} AND end_hour = ${startHour}; \ No newline at end of file + WHERE day_id = ${dayId} AND end_hour = ${startHour}; diff --git a/src/server/sql/holiday/create_holidays_table.sql b/src/server/sql/holiday/create_holidays_table.sql new file mode 100644 index 0000000000..e327e3f69c --- /dev/null +++ b/src/server/sql/holiday/create_holidays_table.sql @@ -0,0 +1,13 @@ +/* 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 holidays ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) NOT NULL CHECK (char_length(name) >= 1), + start_date DATE NOT NULL, + location TEXT NOT NULL CHECK (char_length(location) >= 1), + type TEXT NOT NULL DEFAULT 'custom' CHECK (char_length(type) >= 1), + note TEXT, + UNIQUE (name, location, start_date) +); diff --git a/src/server/sql/holiday/delete_holiday.sql b/src/server/sql/holiday/delete_holiday.sql new file mode 100644 index 0000000000..db19eca19a --- /dev/null +++ b/src/server/sql/holiday/delete_holiday.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/. */ + +DELETE FROM holidays +WHERE id = ${id}; diff --git a/src/server/sql/holiday/get_all.sql b/src/server/sql/holiday/get_all.sql new file mode 100644 index 0000000000..78ff8e7aa6 --- /dev/null +++ b/src/server/sql/holiday/get_all.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holidays +ORDER BY start_date, name, location; diff --git a/src/server/sql/holiday/get_by_id.sql b/src/server/sql/holiday/get_by_id.sql new file mode 100644 index 0000000000..97744bd08d --- /dev/null +++ b/src/server/sql/holiday/get_by_id.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holidays +WHERE id = ${id}; diff --git a/src/server/sql/holiday/get_by_location.sql b/src/server/sql/holiday/get_by_location.sql new file mode 100644 index 0000000000..e2fdc7646c --- /dev/null +++ b/src/server/sql/holiday/get_by_location.sql @@ -0,0 +1,8 @@ +/* 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 * +FROM holidays +WHERE location = ${location} +ORDER BY start_date, name; diff --git a/src/server/sql/holiday/insert_new_holiday.sql b/src/server/sql/holiday/insert_new_holiday.sql new file mode 100644 index 0000000000..fc3c16426d --- /dev/null +++ b/src/server/sql/holiday/insert_new_holiday.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/. */ + +INSERT INTO holidays ( + name, + start_date, + location, + type, + note +) VALUES ( + ${name}, + ${startDate}, + ${location}, + ${type}, + ${note} +) +RETURNING id; diff --git a/src/server/sql/holiday/insert_or_update_holiday.sql b/src/server/sql/holiday/insert_or_update_holiday.sql new file mode 100644 index 0000000000..7577034537 --- /dev/null +++ b/src/server/sql/holiday/insert_or_update_holiday.sql @@ -0,0 +1,21 @@ +/* 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 holidays ( + name, + start_date, + location, + type, + note +) VALUES ( + ${name}, + ${startDate}, + ${location}, + ${type}, + ${note} +) +ON CONFLICT (name, location, start_date) + DO UPDATE SET + type = EXCLUDED.type, + note = COALESCE(EXCLUDED.note, holidays.note); diff --git a/src/server/sql/holiday/update_holiday.sql b/src/server/sql/holiday/update_holiday.sql new file mode 100644 index 0000000000..23864fbeb9 --- /dev/null +++ b/src/server/sql/holiday/update_holiday.sql @@ -0,0 +1,10 @@ +/* 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 holidays + SET name = ${name}, + start_date = ${startDate}, + location = ${location}, + note = ${note} + WHERE id = ${id}; diff --git a/src/server/sql/holidayGroupMember/create_holiday_group_members_table.sql b/src/server/sql/holidayGroupMember/create_holiday_group_members_table.sql new file mode 100644 index 0000000000..633366f7d8 --- /dev/null +++ b/src/server/sql/holidayGroupMember/create_holiday_group_members_table.sql @@ -0,0 +1,9 @@ +/* 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 holiday_group_members ( + holiday_instance_group_id INTEGER NOT NULL REFERENCES holiday_instance_group(id), + holiday_instance_id INTEGER NOT NULL REFERENCES holiday_instance(id), + PRIMARY KEY (holiday_instance_group_id, holiday_instance_id) +); diff --git a/src/server/sql/holidayGroupMember/delete_by_group_id.sql b/src/server/sql/holidayGroupMember/delete_by_group_id.sql new file mode 100644 index 0000000000..c0f402442d --- /dev/null +++ b/src/server/sql/holidayGroupMember/delete_by_group_id.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/. */ + +DELETE FROM holiday_group_members +WHERE holiday_instance_group_id = ${holidayInstanceGroupId}; diff --git a/src/server/sql/holidayGroupMember/delete_holiday_group_member.sql b/src/server/sql/holidayGroupMember/delete_holiday_group_member.sql new file mode 100644 index 0000000000..f5c5f6ca86 --- /dev/null +++ b/src/server/sql/holidayGroupMember/delete_holiday_group_member.sql @@ -0,0 +1,7 @@ +/* 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/. */ + +DELETE FROM holiday_group_members +WHERE holiday_instance_group_id = ${holidayInstanceGroupId} + AND holiday_instance_id = ${holidayInstanceId}; diff --git a/src/server/sql/holidayGroupMember/get_by_group_id.sql b/src/server/sql/holidayGroupMember/get_by_group_id.sql new file mode 100644 index 0000000000..5952328fb5 --- /dev/null +++ b/src/server/sql/holidayGroupMember/get_by_group_id.sql @@ -0,0 +1,20 @@ +/* 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 + hgm.holiday_instance_group_id, + hgm.holiday_instance_id, + hi.name AS holiday_instance_name, + hi.holiday_id, + hi.day_pattern_id, + h.name AS holiday_name, + h.start_date, + h.location, + dp.name AS day_pattern_name +FROM holiday_group_members hgm + INNER JOIN holiday_instance hi ON hgm.holiday_instance_id = hi.id + INNER JOIN holidays h ON hi.holiday_id = h.id + INNER JOIN day_patterns dp ON hi.day_pattern_id = dp.id +WHERE hgm.holiday_instance_group_id = ${holidayInstanceGroupId} +ORDER BY h.start_date, h.name, hi.name; diff --git a/src/server/sql/holidayGroupMember/get_holiday_dates_by_group_id.sql b/src/server/sql/holidayGroupMember/get_holiday_dates_by_group_id.sql new file mode 100644 index 0000000000..1197cd2c72 --- /dev/null +++ b/src/server/sql/holidayGroupMember/get_holiday_dates_by_group_id.sql @@ -0,0 +1,13 @@ +/* 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 + h.start_date, + COUNT(*) AS holiday_count +FROM holiday_group_members hgm + INNER JOIN holiday_instance hi ON hgm.holiday_instance_id = hi.id + INNER JOIN holidays h ON hi.holiday_id = h.id +WHERE hgm.holiday_instance_group_id = ${holidayInstanceGroupId} +GROUP BY h.start_date +ORDER BY h.start_date; diff --git a/src/server/sql/holidayGroupMember/insert_new_holiday_group_member.sql b/src/server/sql/holidayGroupMember/insert_new_holiday_group_member.sql new file mode 100644 index 0000000000..ce4bbf9ec6 --- /dev/null +++ b/src/server/sql/holidayGroupMember/insert_new_holiday_group_member.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/. */ + +INSERT INTO holiday_group_members ( + holiday_instance_group_id, + holiday_instance_id +) VALUES ( + ${holidayInstanceGroupId}, + ${holidayInstanceId} +); diff --git a/src/server/sql/holidayInstance/create_holiday_instance_table.sql b/src/server/sql/holidayInstance/create_holiday_instance_table.sql new file mode 100644 index 0000000000..6a51ebcbe2 --- /dev/null +++ b/src/server/sql/holidayInstance/create_holiday_instance_table.sql @@ -0,0 +1,12 @@ +/* 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 holiday_instance ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) UNIQUE NOT NULL CHECK (char_length(name) >= 1), + note TEXT, + holiday_id INTEGER NOT NULL REFERENCES holidays(id), + day_pattern_id INTEGER NOT NULL REFERENCES day_patterns(id), + UNIQUE (holiday_id, day_pattern_id) +); diff --git a/src/server/sql/holidayInstance/delete_holiday_instance.sql b/src/server/sql/holidayInstance/delete_holiday_instance.sql new file mode 100644 index 0000000000..dde64a52aa --- /dev/null +++ b/src/server/sql/holidayInstance/delete_holiday_instance.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/. */ + +DELETE FROM holiday_instance +WHERE id = ${id}; diff --git a/src/server/sql/holidayInstance/get_all.sql b/src/server/sql/holidayInstance/get_all.sql new file mode 100644 index 0000000000..3364002426 --- /dev/null +++ b/src/server/sql/holidayInstance/get_all.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holiday_instance +ORDER BY name; diff --git a/src/server/sql/holidayInstance/get_by_holiday_id.sql b/src/server/sql/holidayInstance/get_by_holiday_id.sql new file mode 100644 index 0000000000..b40afa2928 --- /dev/null +++ b/src/server/sql/holidayInstance/get_by_holiday_id.sql @@ -0,0 +1,8 @@ +/* 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 * +FROM holiday_instance +WHERE holiday_id = ${holidayId} +ORDER BY name; diff --git a/src/server/sql/holidayInstance/get_by_id.sql b/src/server/sql/holidayInstance/get_by_id.sql new file mode 100644 index 0000000000..af7965be1f --- /dev/null +++ b/src/server/sql/holidayInstance/get_by_id.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holiday_instance +WHERE id = ${id}; diff --git a/src/server/sql/holidayInstance/get_with_details.sql b/src/server/sql/holidayInstance/get_with_details.sql new file mode 100644 index 0000000000..66f578577c --- /dev/null +++ b/src/server/sql/holidayInstance/get_with_details.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 + hi.id, + hi.name, + hi.note, + hi.holiday_id, + hi.day_pattern_id, + h.name AS holiday_name, + h.start_date, + h.location, + dp.name AS day_pattern_name +FROM holiday_instance hi + INNER JOIN holidays h ON hi.holiday_id = h.id + INNER JOIN day_patterns dp ON hi.day_pattern_id = dp.id +ORDER BY hi.name; diff --git a/src/server/sql/holidayInstance/insert_new_holiday_instance.sql b/src/server/sql/holidayInstance/insert_new_holiday_instance.sql new file mode 100644 index 0000000000..1c27afb9e6 --- /dev/null +++ b/src/server/sql/holidayInstance/insert_new_holiday_instance.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/. */ + +INSERT INTO holiday_instance ( + name, + note, + holiday_id, + day_pattern_id +) VALUES ( + ${name}, + ${note}, + ${holidayId}, + ${dayPatternId} +) +RETURNING id; diff --git a/src/server/sql/holidayInstance/update_holiday_instance.sql b/src/server/sql/holidayInstance/update_holiday_instance.sql new file mode 100644 index 0000000000..8bbd408e46 --- /dev/null +++ b/src/server/sql/holidayInstance/update_holiday_instance.sql @@ -0,0 +1,10 @@ +/* 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 holiday_instance + SET name = ${name}, + note = ${note}, + holiday_id = ${holidayId}, + day_pattern_id = ${dayPatternId} + WHERE id = ${id}; diff --git a/src/server/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql b/src/server/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql new file mode 100644 index 0000000000..085f7680f0 --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/create_holiday_instance_group_table.sql @@ -0,0 +1,9 @@ +/* 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 holiday_instance_group ( + id SERIAL PRIMARY KEY NOT NULL, + name VARCHAR(50) UNIQUE NOT NULL CHECK (char_length(name) >= 1), + note TEXT +); diff --git a/src/server/sql/holidayInstanceGroup/delete_holiday_instance_group.sql b/src/server/sql/holidayInstanceGroup/delete_holiday_instance_group.sql new file mode 100644 index 0000000000..ca4ad2146d --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/delete_holiday_instance_group.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/. */ + +DELETE FROM holiday_instance_group +WHERE id = ${id}; diff --git a/src/server/sql/holidayInstanceGroup/get_all.sql b/src/server/sql/holidayInstanceGroup/get_all.sql new file mode 100644 index 0000000000..8edaeecf27 --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/get_all.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holiday_instance_group +ORDER BY name; diff --git a/src/server/sql/holidayInstanceGroup/get_by_id.sql b/src/server/sql/holidayInstanceGroup/get_by_id.sql new file mode 100644 index 0000000000..744889243e --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/get_by_id.sql @@ -0,0 +1,7 @@ +/* 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 * +FROM holiday_instance_group +WHERE id = ${id}; diff --git a/src/server/sql/holidayInstanceGroup/insert_new_holiday_instance_group.sql b/src/server/sql/holidayInstanceGroup/insert_new_holiday_instance_group.sql new file mode 100644 index 0000000000..6d42a7e3af --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/insert_new_holiday_instance_group.sql @@ -0,0 +1,12 @@ +/* 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 holiday_instance_group ( + name, + note +) VALUES ( + ${name}, + ${note} +) +RETURNING id; diff --git a/src/server/sql/holidayInstanceGroup/update_holiday_instance_group.sql b/src/server/sql/holidayInstanceGroup/update_holiday_instance_group.sql new file mode 100644 index 0000000000..f0ea1b725f --- /dev/null +++ b/src/server/sql/holidayInstanceGroup/update_holiday_instance_group.sql @@ -0,0 +1,8 @@ +/* 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 holiday_instance_group + SET name = ${name}, + note = ${note} + WHERE id = ${id}; diff --git a/src/server/sql/week/add_holiday_instance_group_id.sql b/src/server/sql/week/add_holiday_instance_group_id.sql new file mode 100644 index 0000000000..9a7e8ac511 --- /dev/null +++ b/src/server/sql/week/add_holiday_instance_group_id.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/. */ + +ALTER TABLE week_patterns + ADD COLUMN IF NOT EXISTS holiday_instance_group_id INTEGER REFERENCES holiday_instance_group(id); diff --git a/src/server/sql/week/create_week_patterns_table.sql b/src/server/sql/week/create_week_patterns_table.sql index 62cae21def..24e2c3ff4e 100644 --- a/src/server/sql/week/create_week_patterns_table.sql +++ b/src/server/sql/week/create_week_patterns_table.sql @@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS week_patterns ( wednesday INTEGER NOT NULL REFERENCES day_patterns(id), thursday INTEGER NOT NULL REFERENCES day_patterns(id), friday INTEGER NOT NULL REFERENCES day_patterns(id), - saturday INTEGER NOT NULL REFERENCES day_patterns(id) -); \ No newline at end of file + saturday INTEGER NOT NULL REFERENCES day_patterns(id), + holiday_instance_group_id INTEGER REFERENCES holiday_instance_group(id) +); diff --git a/src/server/sql/week/insert_new_week_pattern.sql b/src/server/sql/week/insert_new_week_pattern.sql index f8e44ac63c..7805509198 100644 --- a/src/server/sql/week/insert_new_week_pattern.sql +++ b/src/server/sql/week/insert_new_week_pattern.sql @@ -12,7 +12,8 @@ INSERT INTO week_patterns ( wednesday, thursday, friday, - saturday + saturday, + holiday_instance_group_id ) VALUES ( ${name}, ${note}, @@ -20,7 +21,8 @@ INSERT INTO week_patterns ( ${monday}, ${tuesday}, ${wednesday}, - ${thursday}, + ${thursday}, ${friday}, - ${saturday} -); \ No newline at end of file + ${saturday}, + ${holidayInstanceGroupId} +); diff --git a/src/server/sql/week/update_week_pattern.sql b/src/server/sql/week/update_week_pattern.sql index 43a7fe1c94..824bc30eb3 100644 --- a/src/server/sql/week/update_week_pattern.sql +++ b/src/server/sql/week/update_week_pattern.sql @@ -12,5 +12,6 @@ UPDATE week_patterns wednesday = ${wednesday}, thursday = ${thursday}, friday = ${friday}, - saturday = ${saturday} - WHERE id = ${id}; \ No newline at end of file + saturday = ${saturday}, + holiday_instance_group_id = ${holidayInstanceGroupId} + WHERE id = ${id};