From 70c4e39f54a2ceea72f0d283dd388cc8638948b5 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Sun, 29 Jun 2025 18:28:40 -0600 Subject: [PATCH 001/170] updated layout for create a conversion modal, changed scripts from CRLF to LF --- .gitignore | 3 + docker-compose.yml | 5 +- .../CreateConversionModalComponent.tsx | 56 +++++++++++++++++-- .../app/redux/selectors/adminSelectors.ts | 3 +- src/client/app/translations/data.ts | 3 +- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 0910e89c44..dd7914a64a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ src/server/tmp/* #ui-testing screenshots src/cypress/screenshots/* + +#docker +docker-compose.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1b354cf399..8059f4e9ac 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. volumes: - - ./postgres-data:/var/lib/postgresql/data/pgdata + - pgdata:/var/lib/postgresql/data/pgdata healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s @@ -111,4 +111,5 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" - \ No newline at end of file +volumes: + pgdata: \ No newline at end of file diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index a853c00abe..91cb858ecf 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -1,3 +1,4 @@ +/* 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/. */ @@ -46,7 +47,9 @@ export default function CreateConversionModalComponent() { const handleShow = () => setShowModal(true); // Handlers for each type of input change - const [conversionState, setConversionState] = useState(defaultValues); + const [conversionState, setConversionState] = useState({ + ...defaultValues + }); // If the currently selected conversion is valid const [validConversion, reason] = useAppSelector(state => selectIsValidConversion(state, conversionState)); @@ -77,6 +80,17 @@ export default function CreateConversionModalComponent() { setConversionState(state => ({ ...state, [e.target.name]: Number(e.target.value) })); } }; + + const handlePatternChange = (e: React.ChangeEvent) => { + const selectedPattern = (e.target as HTMLSelectElement).value; + + setConversionState(prev => ({ + ...prev, + pattern: selectedPattern, + slope: selectedPattern === 'No Pattern' ? prev.slope : 0, + intercept: selectedPattern === 'No Pattern' ? prev.intercept : 0 + })); + }; /* End State */ // Determines whether the selected source is of type meter @@ -248,6 +262,18 @@ export default function CreateConversionModalComponent() { )} + {/* Note input for overall conversion*/} + + + handleStringChange(e)} + value={conversionState.note} /> + + {/*Initial conversion*/} +
Initial Conversion
{/* Slope input*/} @@ -258,7 +284,8 @@ export default function CreateConversionModalComponent() { name='slope' type='number' value={conversionState.slope} - onChange={e => handleNumberChange(e)} /> + onChange={e => handleNumberChange(e)} + disabled={conversionState.pattern !== 'No Pattern'} /> @@ -270,15 +297,34 @@ export default function CreateConversionModalComponent() { name='intercept' type='number' value={conversionState.intercept} - onChange={e => handleNumberChange(e)} /> + onChange={e => handleNumberChange(e)} + disabled={conversionState.pattern !== 'No Pattern'} /> - {/* Note input*/} + {/* Pattern dropdown for weekly pattern or no pattern */} + + + + {/* + {defaultValues.weeklyPatterns?.map(p => ( + + ))} */} + + + + + + {/* Note input for initial conversion*/} handleStringChange(e)} diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index a17436adb7..fab94e5e43 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -331,9 +331,10 @@ export const selectDefaultCreateConversionValues = createAppSelector( destinationId: -999, destinationOptions: sortedUnitData.filter(unit => unit.typeOfUnit !== 'meter'), bidirectional: true, + note: '', slope: 0, intercept: 0, - note: '' + pattern: 'No Pattern' }; return defaultValues; } diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 60490fb536..52a3d0f7b2 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -83,7 +83,7 @@ const LocaleTranslationData = { "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.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.", @@ -99,6 +99,7 @@ const LocaleTranslationData = { "conversion.successfully.create.conversion": "Successfully created a conversion.", "conversion.successfully.delete.conversion": "Successfully deleted conversion.", "conversion.successfully.edited.conversion": "Successfully edited conversion.", + "conversion.pattern": "Pattern:", "conversions": "Conversions", "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", From de6ee879c7edc41f8fce6fd0ca1cc912530539c4 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Mon, 7 Jul 2025 08:54:08 -0600 Subject: [PATCH 002/170] changed state in create conversion modal to be compitable with new updates --- .../CreateConversionModalComponent.tsx | 287 ++++++++++++++---- .../app/redux/selectors/adminSelectors.ts | 5 +- 2 files changed, 225 insertions(+), 67 deletions(-) diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index 91cb858ecf..afc3165b32 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -3,7 +3,6 @@ * 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 { FormattedMessage } from 'react-intl'; @@ -48,61 +47,165 @@ export default function CreateConversionModalComponent() { // Handlers for each type of input change const [conversionState, setConversionState] = useState({ - ...defaultValues - }); + overallConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + note: defaultValues.overallConversionNote + }, + initialConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + slope: defaultValues.slope, + intercept: defaultValues.intercept, + pattern: defaultValues.weeklyPattern, + note: defaultValues.initialConversionNote + }, + sourceOptions: defaultValues.sourceOptions, + destinationOptions: defaultValues.destinationOptions, + weeklyPatterns: defaultValues.weeklyPattern +}); // If the currently selected conversion is valid - const [validConversion, reason] = useAppSelector(state => selectIsValidConversion(state, conversionState)); + // TODO: Add a check for the weekly pattern as well. + const [validConversion, reason] = useAppSelector(state => + selectIsValidConversion(state, { + sourceId: conversionState.initialConversion.sourceId, + destinationId: conversionState.initialConversion.destinationId, + bidirectional: conversionState.initialConversion.bidirectional, + slope: conversionState.initialConversion.slope, + intercept: conversionState.initialConversion.intercept, + //pattern: conversionState.initialConversion.pattern, + note: conversionState.initialConversion.note + }) + ); const handleStringChange = (e: React.ChangeEvent) => { - setConversionState({ ...conversionState, [e.target.name]: e.target.value }); - }; + const { name, value } = e.target; + if (name === 'overallConversionNote') { + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + note: value + } + })); + } else if (name === 'initialConversionNote') { + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + note: value + } + })); + } +}; + // const handleBooleanChange = (e: React.ChangeEvent) => { + // setConversionState({ ...conversionState, [e.target.name]: JSON.parse(e.target.value) }); + // }; - const handleBooleanChange = (e: React.ChangeEvent) => { - setConversionState({ ...conversionState, [e.target.name]: JSON.parse(e.target.value) }); - }; + // 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 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 handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newValue = Number(value); + + setConversionState(prev => { + if (name === 'sourceId') { + return { + ...prev, + overallConversion: { + ...prev.overallConversion, + sourceId: newValue + }, + initialConversion: { + ...prev.initialConversion, + sourceId: newValue + }, + destinationOptions: defaultValues.destinationOptions.filter(destination => destination.id !== newValue) + }; + } else if (name === 'destinationId') { + return { + ...prev, + overallConversion: { + ...prev.overallConversion, + destinationId: newValue + }, + initialConversion: { + ...prev.initialConversion, + destinationId: newValue + }, + sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) + }; + } else { + return { + ...prev, + initialConversion: { + ...prev.initialConversion, + [name]: newValue + } + }; + } + }); }; - const handlePatternChange = (e: React.ChangeEvent) => { - const selectedPattern = (e.target as HTMLSelectElement).value; + const handleBooleanChange = (e: React.ChangeEvent) => { + const value = e.target.value === 'true'; + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + bidirectional: value + }, + initialConversion: { + ...prev.initialConversion, + bidirectional: value + } + })); + }; + const handleInitialPatternChange = (e: React.ChangeEvent) => { + const selectedPattern = e.target.value; setConversionState(prev => ({ ...prev, - pattern: selectedPattern, - slope: selectedPattern === 'No Pattern' ? prev.slope : 0, - intercept: selectedPattern === 'No Pattern' ? prev.intercept : 0 + initialConversion: { + ...prev.initialConversion, + pattern: selectedPattern, + slope: selectedPattern === 'No Pattern' ? prev.initialConversion.slope : 0, + intercept: selectedPattern === 'No Pattern' ? prev.initialConversion.intercept : 0 + } })); }; /* End State */ // Determines whether the selected source is of type meter const isMeterSource = () => { - const source = defaultValues.sourceOptions.find(u => u.id === conversionState.sourceId); + const source = defaultValues.sourceOptions.find(u => u.id === conversionState.overallConversion.sourceId); return source?.typeOfUnit === UnitType.meter; }; // 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); + 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; }; @@ -111,10 +214,23 @@ export default function CreateConversionModalComponent() { //Close the warning modal setShowWarningModal(false); - //Proceed with the creation of the conversion - setShowModal(false); - addConversionMutation({...omit(conversionState, 'sourceOptions'), - bidirectional: (isMeterSource() || isSuffixUsed()) ? false : conversionState.bidirectional}); + // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready + // Create a placeholder payload for the conversion creation until backend is ready + 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, + pattern: conversionState.initialConversion.pattern, + initialNote: conversionState.initialConversion.note + }; + console.log( + 'sourceId:', conversionState.overallConversion.sourceId, typeof conversionState.overallConversion.sourceId, + 'destinationId:', conversionState.overallConversion.destinationId, typeof conversionState.overallConversion.destinationId + ); + addConversionMutation(payload); resetState(); }; @@ -125,14 +241,33 @@ export default function CreateConversionModalComponent() { // Reset the state to default values const resetState = () => { - setConversionState(defaultValues); + setConversionState({ + overallConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + note: defaultValues.overallConversionNote + }, + initialConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + slope: defaultValues.slope, + intercept: defaultValues.intercept, + pattern: defaultValues.weeklyPattern, + note: defaultValues.initialConversionNote + }, + sourceOptions: defaultValues.sourceOptions, + destinationOptions: defaultValues.destinationOptions, + weeklyPatterns: defaultValues.weeklyPattern + }); }; /* End Warning Modal */ // Submit const handleSubmit = () => { // Show warning modal if slope and intercept are both 0 - if (conversionState.slope === 0 && conversionState.intercept === 0) { + if (conversionState.initialConversion.slope === 0 && conversionState.initialConversion.intercept === 0) { setWarningMessage(translate('conversion.slope.intercept.zero')); setShowWarningModal(true); } else if (validConversion) { @@ -142,8 +277,29 @@ export default function CreateConversionModalComponent() { // 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}); + + // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready + // Example updated logic for creating a conversion: + // 1. Create overall conversion + //const overallResult = await addConversionMutation(conversionState.overallConversion).unwrap(); + // 2. Create initial conversion with foreign key + // await addConversionMutation({ + // ...conversionState.initialConversion, + // overallConversionId: overallResult.id + // }); + + // Create a placeholder payload for the conversion creation until backend is ready + 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, + pattern: conversionState.initialConversion.pattern, + initialNote: conversionState.initialConversion.note + }; + addConversionMutation(payload); resetState(); } else { showErrorNotification(reason); @@ -190,13 +346,13 @@ export default function CreateConversionModalComponent() { id='sourceId' name='sourceId' type='select' - value={conversionState.sourceId} + value={conversionState.overallConversion.sourceId} onChange={e => handleNumberChange(e)} - invalid={conversionState.sourceId === -999}> + invalid={conversionState.overallConversion.sourceId === -999}> {} @@ -217,13 +373,13 @@ export default function CreateConversionModalComponent() { id='destinationId' name='destinationId' type='select' - value={conversionState.destinationId} + value={conversionState.overallConversion.destinationId} onChange={e => handleNumberChange(e)} - invalid={conversionState.destinationId === -999}> + invalid={conversionState.overallConversion.destinationId === -999}> {} @@ -245,18 +401,18 @@ export default function CreateConversionModalComponent() { name='bidirectional' type='select' onChange={e => handleBooleanChange(e)} - value={String(conversionState.bidirectional)} - invalid={(isMeterSource() || isSuffixUsed()) && conversionState.bidirectional === true}> + 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 && ( @@ -266,11 +422,11 @@ export default function CreateConversionModalComponent() { handleStringChange(e)} - value={conversionState.note} /> + value={conversionState.overallConversion.note} /> {/*Initial conversion*/}
Initial Conversion
@@ -283,9 +439,9 @@ export default function CreateConversionModalComponent() { id='slope' name='slope' type='number' - value={conversionState.slope} + value={conversionState.initialConversion.slope} onChange={e => handleNumberChange(e)} - disabled={conversionState.pattern !== 'No Pattern'} /> + disabled={conversionState.initialConversion.pattern !== 'No Pattern'} />
@@ -296,9 +452,9 @@ export default function CreateConversionModalComponent() { id='intercept' name='intercept' type='number' - value={conversionState.intercept} + value={conversionState.initialConversion.intercept} onChange={e => handleNumberChange(e)} - disabled={conversionState.pattern !== 'No Pattern'} /> + disabled={conversionState.initialConversion.pattern !== 'No Pattern'} /> @@ -309,9 +465,10 @@ export default function CreateConversionModalComponent() { id='pattern' name='pattern' type='select' - value={conversionState.pattern} - onChange={handlePatternChange}> - {/* + value={conversionState.initialConversion.pattern} + onChange={handleInitialPatternChange}> + {/* TODO: Replace with actual patterns from the backend + {defaultValues.weeklyPatterns?.map(p => ( ))} */} @@ -324,11 +481,11 @@ export default function CreateConversionModalComponent() { handleStringChange(e)} - value={conversionState.note} /> + value={conversionState.initialConversion.note} /> diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index fab94e5e43..f076a8517b 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -331,10 +331,11 @@ export const selectDefaultCreateConversionValues = createAppSelector( destinationId: -999, destinationOptions: sortedUnitData.filter(unit => unit.typeOfUnit !== 'meter'), bidirectional: true, - note: '', + overallConversionNote: '', + initialConversionNote: '', slope: 0, intercept: 0, - pattern: 'No Pattern' + weeklyPattern: 'No Pattern' }; return defaultValues; } From ffaf4a964daa8adb88f342848f66c86b2f02cbb9 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Tue, 8 Jul 2025 11:09:57 -0600 Subject: [PATCH 003/170] created an overview page for daily patterns --- .../app/components/HeaderButtonsComponent.tsx | 9 ++ src/client/app/components/RouteComponent.tsx | 2 + .../components/days/DayDetailComponent.tsx | 89 +++++++++++++++++++ .../app/components/days/DayViewComponent.tsx | 68 ++++++++++++++ src/client/app/redux/api/daysApi.ts | 89 +++++++++++++++++++ src/client/app/translations/data.ts | 14 +++ src/client/app/types/redux/days.ts | 35 ++++++++ 7 files changed, 306 insertions(+) create mode 100644 src/client/app/components/days/DayDetailComponent.tsx create mode 100644 src/client/app/components/days/DayViewComponent.tsx create mode 100644 src/client/app/redux/api/daysApi.ts create mode 100644 src/client/app/types/redux/days.ts diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index 8aa3fe2fb3..cfb5886ce6 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -64,6 +64,7 @@ export default function HeaderButtonsComponent() { shouldCSVReadingsButtonDisabled: true, shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, + shouldDailyPatternsButtonDisabled: true, shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, // Translated menu title that depend on whether logged in. @@ -102,6 +103,7 @@ export default function HeaderButtonsComponent() { shouldCSVReadingsButtonDisabled: pathname === '/csvReadings', shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', + shouldDailyPatternsButtonDisabled: pathname === '/dailyPatterns', shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' })); @@ -191,6 +193,13 @@ export default function HeaderButtonsComponent() { to="/conversions"> + + + }, { path: 'calibration', element: }, { path: 'conversions', element: }, + { path: 'dailyPatterns', element: }, { path: 'csvMeters', element: }, { path: 'maps', element: }, { path: 'units', element: }, diff --git a/src/client/app/components/days/DayDetailComponent.tsx b/src/client/app/components/days/DayDetailComponent.tsx new file mode 100644 index 0000000000..2dd477dca8 --- /dev/null +++ b/src/client/app/components/days/DayDetailComponent.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import SpinnerComponent from '../SpinnerComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import { Day } from '../../types/redux/days'; +import DayViewComponent from './DayViewComponent'; +import { Button } from 'reactstrap'; + +/** + * Hardcoded day data for demonstration. + */ +const hardcodedDays: Day[] = [ + { + id: '1', + name: 'Weekday', + note: 'Standard weekday pattern', + segments: [ + { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, + { id: '1-2', dayId: '1', hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, + { id: '1-3', dayId: '1', hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } + ] + }, + { + id: '2', + name: 'Weekend', + note: 'Weekend pattern', + segments: [ + { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } + ] + } +]; + +/** + * Defines the daily pattern info page + * @param props defined above + * @returns Single day element + */ +export default function DayDetailComponent() { + const locale = useAppSelector(selectSelectedLanguage); + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipDayView: 'help.admin.dailypatternview' + }; + + // Simulate loading state if needed + const [loading] = React.useState(false); + + return ( +
+ {loading ? ( +
+ + +
+ ) : ( +
+ + +
+

+ +
+ +
+

+
+ {/* Placeholder for create day modal */} + +
+
+ {hardcodedDays + .sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase(), locale, { sensitivity: 'accent' })) + .map(day => ( + + ))} +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/client/app/components/days/DayViewComponent.tsx b/src/client/app/components/days/DayViewComponent.tsx new file mode 100644 index 0000000000..9cd09bb99c --- /dev/null +++ b/src/client/app/components/days/DayViewComponent.tsx @@ -0,0 +1,68 @@ +/* eslint-disable no-trailing-spaces */ +import * as React from 'react'; +// Realize that * is already imported from react +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button } from 'reactstrap'; +import { Day } from 'types/redux/days'; +import '../../styles/card-page.css'; +//import { useTranslate } from '../../redux/componentHooks'; +//import EditConversionModalComponent from './EditConversionModalComponent'; + +interface DayViewComponentProps { + day: Day; +} + +/** + * Defines the daily pattern info card + * @param props defined above + * @returns Single day element + */ +export default function DayViewComponent(props: DayViewComponentProps) { + //const translate = useTranslate(); + // Don't check if admin since only an admin is allow to route to this page. + + // Edit Modal Show + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [showEditModal, setShowEditModal] = useState(false); + //const unitDataById = useAppSelector(selectUnitDataById); + + const handleShow = () => { + setShowEditModal(true); + }; + + // const handleClose = () => { + // setShowEditModal(false); + // }; + + // Create header from sourceId, destinationId identifiers + //const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + + // unitDataById[props.conversion.destinationId]?.identifier); + const dayIdentifier = props.day.name || 'Unnamed Day'; + + // Unlike the details component, we don't check if units are loaded since must come through that page. + + return ( +
+
+ {dayIdentifier} +
+
+ {/* 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/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts new file mode 100644 index 0000000000..01eb336f75 --- /dev/null +++ b/src/client/app/redux/api/daysApi.ts @@ -0,0 +1,89 @@ +//import { createSelector } from '@reduxjs/toolkit'; +import { Day, DaySegment } from '../../types/redux/days'; +//import { baseApi } from './baseApi'; + +// Inject endpoints into baseApi for consistency with conversionsApi +// export const daysApi = baseApi.injectEndpoints({ +// endpoints: builder => ({ +// getDaysDetails: builder.query({ +// query: () => 'api/days', +// providesTags: ['DayDetails'] +// }), +// getDayById: builder.query({ +// query: id => `api/days/${id}`, +// providesTags: (result, error, id) => [{ type: 'DayDetails', id }] +// }), +// addDay: builder.mutation>({ +// query: newDay => ({ +// url: 'api/days', +// method: 'POST', +// body: newDay +// }), +// invalidatesTags: ['DayDetails'] +// }), +// editDay: builder.mutation & Pick>({ +// query: ({ id, ...updatedDay }) => ({ +// url: `api/days/${id}`, +// method: 'PUT', +// body: updatedDay +// }), +// invalidatesTags: ['DayDetails'] +// }), +// deleteDay: builder.mutation({ +// query: id => ({ +// url: `api/days/${id}`, +// method: 'DELETE' +// }), +// invalidatesTags: ['DayDetails'] +// }), +// getDaySegments: builder.query({ +// query: dayId => `api/days/${dayId}/segments`, +// providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] +// }), +// addDaySegment: builder.mutation }>({ +// query: ({ dayId, segment }) => ({ +// url: `api/days/${dayId}/segments`, +// method: 'POST', +// body: segment +// }), +// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] +// }), +// editDaySegment: builder.mutation }>({ +// query: ({ dayId, segmentId, segment }) => ({ +// url: `api/days/${dayId}/segments/${segmentId}`, +// method: 'PUT', +// body: segment +// }), +// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] +// }), +// deleteDaySegment: builder.mutation({ +// query: ({ dayId, segmentId }) => ({ +// url: `api/days/${dayId}/segments/${segmentId}`, +// method: 'DELETE' +// }), +// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] +// }) +// }) +// }); + +// Selectors for days +//export const selectDaysQueryState = daysApi.endpoints.getDaysDetails.select(); +// export const selectDaysDetails = createSelector( +// selectDaysQueryState, +// ({ data: daysData = [] }) => daysData +// ); + +export const stableEmptyDays: Day[] = []; +export const stableEmptyDaySegments: DaySegment[] = []; + +// export const { +// useGetDaysDetailsQuery, +// useGetDayByIdQuery, +// useAddDayMutation, +// useEditDayMutation, +// useDeleteDayMutation, +// useGetDaySegmentsQuery, +// useAddDaySegmentMutation, +// useEditDaySegmentMutation, +// useDeleteDaySegmentMutation +// } \ No newline at end of file diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 52a3d0f7b2..9857bb4001 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -102,6 +102,9 @@ const LocaleTranslationData = { "conversion.pattern": "Pattern:", "conversions": "Conversions", "ConversionType.conversion": "conversion", + "daily.patterns": "Daily Patterns", + "daily.patterns.create": "Create a Daily Pattern", + "daily.patterns.edit": "Details/Edit Daily Pattern", "create.conversion": "Create a Conversion", "create.group": "Create a Group", "create.map": "Create a Map", @@ -247,6 +250,7 @@ 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.dailypatternview": "This page shows information on daily patterns. 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.", @@ -671,7 +675,11 @@ const LocaleTranslationData = { "conversion.successfully.delete.conversion": "Successfully deleted conversion.\u{26A1}", "conversion.successfully.edited.conversion": "Successfully edited conversion.\u{26A1}", "conversions": "Conversions\u{26A1}", + "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", + "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", "create.group": "Créer un Groupe", "create.map": "Créer une carte", @@ -817,6 +825,7 @@ 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.dailypatternview": "This page shows information on daily patterns. 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}", @@ -1241,7 +1250,11 @@ const LocaleTranslationData = { "conversion.successfully.delete.conversion": "Se eliminó una conversión con éxito.", "conversion.successfully.edited.conversion": "Se editó una conversión con éxito", "conversions": "Conversiones", + "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversión", + "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "create.conversion": "Crear una conversión", "create.group": "Crear un grupo", "create.map": "Crear un mapa", @@ -1387,6 +1400,7 @@ 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.dailypatternview": "This page shows information on daily patterns. 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.", diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts new file mode 100644 index 0000000000..240071581d --- /dev/null +++ b/src/client/app/types/redux/days.ts @@ -0,0 +1,35 @@ +export interface Day { + id: string; + name: string; + note: string; + segments: DaySegment[]; +} + +export interface DaySegment { + id: string; + dayId: string; + hour: number; + slope: number; + intercept: number; + note?: string; +} + +export interface CreateDaySegmentPayload { + dayId: string; + hour: number; + slope: number; + intercept: number; + note?: string; +} + +export interface UpdateDaySegmentPayload { + id: string; + hour: number; + slope: number; + intercept: number; + note?: string; +} + +export interface DeleteDaySegmentPayload { + id: string; +} \ No newline at end of file From e72bbd75b7227efadc8e105f42a06347be13aeb7 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Tue, 8 Jul 2025 11:11:12 -0600 Subject: [PATCH 004/170] changed component folder name from days to daily-pattern --- src/client/app/components/RouteComponent.tsx | 2 +- .../components/{days => daily-pattern}/DayDetailComponent.tsx | 0 .../app/components/{days => daily-pattern}/DayViewComponent.tsx | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/client/app/components/{days => daily-pattern}/DayDetailComponent.tsx (100%) rename src/client/app/components/{days => daily-pattern}/DayViewComponent.tsx (100%) diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index f5965dce92..2634850c6f 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -27,7 +27,7 @@ import ErrorComponent from './router/ErrorComponent'; import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; import LogMsgComponent from './admin/LogMsgComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; -import DayDetailComponent from './days/DayDetailComponent'; +import DayDetailComponent from './daily-pattern/DayDetailComponent'; /** * @returns the router component Responsible for client side routing. diff --git a/src/client/app/components/days/DayDetailComponent.tsx b/src/client/app/components/daily-pattern/DayDetailComponent.tsx similarity index 100% rename from src/client/app/components/days/DayDetailComponent.tsx rename to src/client/app/components/daily-pattern/DayDetailComponent.tsx diff --git a/src/client/app/components/days/DayViewComponent.tsx b/src/client/app/components/daily-pattern/DayViewComponent.tsx similarity index 100% rename from src/client/app/components/days/DayViewComponent.tsx rename to src/client/app/components/daily-pattern/DayViewComponent.tsx From e4370352fdc2f52ed102fb9d67b09ee49467e098 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Sun, 13 Jul 2025 14:34:25 -0700 Subject: [PATCH 005/170] Initial UI for the weekly conversion patterns feature Currently read-only. Uses fake data, as the Weeks API is not yet implemented. --- .../app/components/HeaderButtonsComponent.tsx | 15 +- src/client/app/components/RouteComponent.tsx | 14 +- .../weeks/CreateWeekModalComponent.tsx | 155 ++++++++++++++++ .../weeks/EditWeekModalComponent.tsx | 168 ++++++++++++++++++ .../components/weeks/WeekViewComponent.tsx | 41 +++++ .../components/weeks/WeeksDetailComponent.tsx | 43 +++++ .../app/components/weeks/fake-week-data.ts | 39 ++++ src/client/app/redux/api/baseApi.ts | 3 +- src/client/app/redux/api/weeksApi.ts | 69 +++++++ .../app/redux/selectors/adminSelectors.ts | 25 ++- src/client/app/translations/data.ts | 45 ++++- src/client/app/types/redux/weeks.ts | 26 +++ 12 files changed, 625 insertions(+), 18 deletions(-) create mode 100644 src/client/app/components/weeks/CreateWeekModalComponent.tsx create mode 100644 src/client/app/components/weeks/EditWeekModalComponent.tsx create mode 100644 src/client/app/components/weeks/WeekViewComponent.tsx create mode 100644 src/client/app/components/weeks/WeeksDetailComponent.tsx create mode 100644 src/client/app/components/weeks/fake-week-data.ts create mode 100644 src/client/app/redux/api/weeksApi.ts create mode 100644 src/client/app/types/redux/weeks.ts diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index cfb5886ce6..a845b654b4 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 @@ -65,6 +65,7 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, shouldDailyPatternsButtonDisabled: true, + shouldWeeksButtonDisabled: true, shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, // Translated menu title that depend on whether logged in. @@ -104,6 +105,7 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', shouldDailyPatternsButtonDisabled: pathname === '/dailyPatterns', + shouldWeeksButtonDisabled: pathname === '/weeks', shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' })); @@ -200,6 +202,13 @@ export default function HeaderButtonsComponent() { to="/dailyPatterns">
+ + + ); -} \ No newline at end of file +} diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index 2634850c6f..b5f2dcc596 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -4,30 +4,31 @@ 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 DayDetailComponent from './daily-pattern/DayDetailComponent'; 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 DayDetailComponent from './daily-pattern/DayDetailComponent'; +import WeeksDetailComponent from './weeks/WeeksDetailComponent'; /** * @returns the router component Responsible for client side routing. @@ -59,13 +60,14 @@ const router = createBrowserRouter([ { path: 'calibration', element: }, { path: 'conversions', element: }, { path: 'dailyPatterns', 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: } ] }, { diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx new file mode 100644 index 0000000000..26963f5474 --- /dev/null +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -0,0 +1,155 @@ +/* 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 { useAppSelector } from '../../redux/reduxHooks'; +import { selectDefaultCreateWeekValues } from '../../redux/selectors/adminSelectors'; +import translate from '../../utils/translate'; +import { fakeDaysData } from './fake-week-data'; + +/** + * 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 [showModal, setShowModal] = React.useState(false); + + const defaultValues = useAppSelector(selectDefaultCreateWeekValues); + + const [weekDetails, setWeekDetails] = React.useState(defaultValues); + + // reference to the submit button + const submitButtonRef = React.useRef(null); + + const onToggleModal = () => { + setShowModal(!showModal); + if (showModal) { + resetState(); + } + }; + + const handleSubmit = () => { + // prevent multiple submissions + submitButtonRef.current?.setAttribute('disabled', 'true'); + + // TODO (evan-carey): Add logic to save the week details + console.log('Week details submitted:', weekDetails); + }; + + const resetState = () => { + setWeekDetails(defaultValues); + }; + + // TODO (evan-carey): Add validation logic + const isValidWeek = true; + + return ( + <> + {/* Show cerate modal button */} + + + + + + + + + + {/* Name */} + + + + + setWeekDetails({ ...weekDetails, name: event.target.value })} /> + + + + + + + {/* Note */} + + + + + setWeekDetails({ ...weekDetails, note: event.target.value })} /> + + + + {/* Days */} + + + + + + + + + + + {(['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const).map(day => ( + + + + + ))} + +
+ setWeekDetails({ ...weekDetails, [day]: event.target.value })} + > + + {/* TODO (evan-carey): populate with real days data */} + {fakeDaysData.map(fakeDay => ( + + ))} + +
+ +
+
+
+ + + + +
+ + ); +} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx new file mode 100644 index 0000000000..7c08097586 --- /dev/null +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -0,0 +1,168 @@ +/* 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 { Week } from '../../types/redux/weeks'; +import translate from '../../utils/translate'; +import { fakeDaysData } from './fake-week-data'; + +interface EditWeekModalComponentProps { + /** + * Whether the modal is visible or not + */ + show: boolean; + + week: Week; + + /** + * 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 values = { ...props.week }; + + const [weekDetails, setWeekDetails] = React.useState(values); + + // reference to the submit button + const submitButtonRef = React.useRef(null); + + const resetState = () => { + setWeekDetails(values); + }; + + // TODO (evan-carey): Add logic to save changes + const handleSubmit = () => { + // prevent multiple submissions + submitButtonRef.current?.setAttribute('disabled', 'true'); + + console.log('Week details submitted:', weekDetails); + + // TODO (evan-carey): Add logic to save the week details, update redux state, close modal, and show success toast + }; + + const handleDelete = () => { + // TODO (evan-carey): Add logic to delete the week + }; + + const handleClose = () => { + props.handleClose(); + resetState(); + }; + + // TODO (evan-carey): Add proper validation logic? + // For now, just check if the name is not empty + const isValidWeek = weekDetails.name.trim() !== ''; + + return ( + <> + + + + + + + {/* Name */} + + + + + setWeekDetails({ ...weekDetails, name: event.target.value })} /> + + + + + + + {/* Note */} + + + + + setWeekDetails({ ...weekDetails, note: event.target.value })} /> + + + + {/* Days */} + + + + + + + + + + + {(['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const).map(day => ( + + + + + ))} + +
+ setWeekDetails({ ...weekDetails, [day]: event.target.value })} + > + {/* TODO (evan-carey): populate with real days data */} + {fakeDaysData.map(fakeDay => ( + + ))} + +
+ +
+
+
+ + {/* Delete week */} + + {/* Discard changes */} + + {/* Save changes */} + + +
+ + ); +} diff --git a/src/client/app/components/weeks/WeekViewComponent.tsx b/src/client/app/components/weeks/WeekViewComponent.tsx new file mode 100644 index 0000000000..49ea99f6cb --- /dev/null +++ b/src/client/app/components/weeks/WeekViewComponent.tsx @@ -0,0 +1,41 @@ +/* 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 { Week } from '../../types/redux/weeks'; +import EditWeekModalComponent from './EditWeekModalComponent'; + +interface WeekViewComponentProps { + week: Week; +} + +/** + * 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}
+
+ {week.note} +
+
+ + + 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..d20ed9d1e1 --- /dev/null +++ b/src/client/app/components/weeks/WeeksDetailComponent.tsx @@ -0,0 +1,43 @@ +/* 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 { titleStyle } from '../../styles/modalStyle'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import CreateWeekModalComponent from './CreateWeekModalComponent'; +import { fakeWeeksData } from './fake-week-data'; +import WeekViewComponent from './WeekViewComponent'; + +/** + * Defines the weekly patterns page + * @returns Weekly patterns page element + */ +export default function WeeksDetailComponent() { + // const locale = useAppSelector(selectSelectedLanguage); + + // TODO (evan-carey): Replace with real weeks data + const weeks = fakeWeeksData; + + return ( +
+
+ + +
+

+ +

+
+ +
+
+ {/* TODO (evan-carey): sort weeks? */} + {weeks.map(week => )} +
+
+
+
+ ); +} diff --git a/src/client/app/components/weeks/fake-week-data.ts b/src/client/app/components/weeks/fake-week-data.ts new file mode 100644 index 0000000000..5c49121022 --- /dev/null +++ b/src/client/app/components/weeks/fake-week-data.ts @@ -0,0 +1,39 @@ +import { Day } from '../../types/redux/days'; +import { Week } from '../../types/redux/weeks'; + +// ! This file contains fake data for development purposes. +// ! It should be replaced with real data from the backend when available. + +export const fakeDaysData: Day[] = [ + { + id: '1', + name: 'Weekday', + note: 'Standard weekday pattern', + segments: [ + { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, + { id: '1-2', dayId: '1', hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, + { id: '1-3', dayId: '1', hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } + ] + }, + { + id: '2', + name: 'Weekend', + note: 'Weekend pattern', + segments: [ + { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } + ] + } +]; + +export const fakeWeeksData: Week[] = new Array(6).fill(null).map((_, index) => ({ + id: index + 1, + name: `Fake Week Pattern ${index + 1}`, + note: 'This is a fake week pattern used for development purposes', + sunday: fakeDaysData[1].id, + monday: fakeDaysData[0].id, + tuesday: fakeDaysData[0].id, + wednesday: fakeDaysData[0].id, + thursday: fakeDaysData[0].id, + friday: fakeDaysData[0].id, + saturday: fakeDaysData[1].id +})); diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 58d58b5e14..507afa2a39 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -35,7 +35,8 @@ export const baseApi = createApi({ 'ConversionDetails', 'Units', 'Cik', - 'Readings' + 'Readings', + 'Weeks' ], // Initially no defined endpoints, Use rtk query's injectEndpoints endpoints: () => ({}) diff --git a/src/client/app/redux/api/weeksApi.ts b/src/client/app/redux/api/weeksApi.ts new file mode 100644 index 0000000000..f6a63a9ffd --- /dev/null +++ b/src/client/app/redux/api/weeksApi.ts @@ -0,0 +1,69 @@ +/* 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 { Week } from '../../types/redux/weeks'; +import { baseApi } from './baseApi'; + +// NOTE (evan-carey): This file is a WIP, as the backend API for weeks is not yet implemented. + +/** + * 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', + providesTags: ['Weeks'] + }), + addWeek: builder.mutation({ + query: week => ({ + url: 'api/weeks/addWeek', + method: 'POST', + body: week + }), + onQueryStarted: async (_arg, api) => { + api.queryFulfilled + .then(() => { + api.dispatch(weeksApi.endpoints.getWeeks.initiate()); + }); + } + }), + deleteWeek: builder.mutation>({ + query: weekId => ({ + url: 'api/weeks/delete', + method: 'POST', + body: weekId + }), + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + queryFulfilled + .then(() => { + dispatch(weeksApi.endpoints.getWeeks.initiate()); + }); + } + }), + editWeek: builder.mutation({ + query: week => ({ + url: 'api/weeks/edit', + method: 'POST', + body: week + }), + onQueryStarted: async (_arg, api) => { + api.queryFulfilled + .then(() => { + api.dispatch(weeksApi.endpoints.getWeeks.initiate()); + }); + } + }) + }) +}); + +export const selectWeeksQueryState = weeksApi.endpoints.getWeeks.select(); +export const selectWeeks = createSelector( + selectWeeksQueryState, + weeksQueryState => weeksQueryState.data ?? [] +); + diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index f076a8517b..17e4a504be 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -7,19 +7,17 @@ import { selectCik, selectConversionsDetails } from '../../redux/api/conversions import { selectAllGroups } from '../../redux/api/groupsApi'; import { selectAllMeters, selectMeterById } from '../../redux/api/metersApi'; import { selectAdminPreferences } from '../../redux/slices/adminSlice'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { ConversionData } from '../../types/redux/conversions'; import { MeterData, MeterTimeSortType } from '../../types/redux/meters'; -import { UnitData, UnitType } from '../../types/redux/units'; +import { DisableChecksType, UnitData, UnitType } from '../../types/redux/units'; 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 { 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'); @@ -341,6 +339,23 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); +// TODO (evan-carey): if Day ID is a number instead of a string, fix this. +export const selectDefaultCreateWeekValues = createAppSelector( + [], + () => ({ + id: -1, + name: '', + note: '', + sunday: '-1', + monday: '-1', + tuesday: '-1', + wednesday: '-1', + thursday: '-1', + friday: '-1', + saturday: '-1' + }) +); + /* Create Meter Validation: Name cannot be blank Area must be positive or zero diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 9857bb4001..086063ad32 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -79,7 +79,7 @@ const LocaleTranslationData = { "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.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", @@ -140,6 +140,14 @@ const LocaleTranslationData = { "custom.value": "Custom value", "date.range": 'Date Range', "day": "Day", + "day.of.week": "Day of Week", + "day.sunday": "Sunday", + "day.monday": "Monday", + "day.tuesday": "Tuesday", + "day.wednesday": "Wednesday", + "day.thursday": "Thursday", + "day.friday": "Friday", + "day.saturday": "Saturday", "days": "Days", "days.enter": "Enter in days and then hit enter", "decreasing": "decreasing", @@ -166,6 +174,7 @@ const LocaleTranslationData = { "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", @@ -465,6 +474,7 @@ const LocaleTranslationData = { "save.role.changes": "Save role changes", "second": "Second", "select.all": "Select All", + "select.day": "Select Day", "select.groups": "Select Groups", "select.map": "Select Map", "select.meter": "Select Meter", @@ -577,6 +587,9 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics", "website": "website", "week": "Week", + "week.create": "Create a Week", + "week.edit": "Edit Week", + "weeks": "Weeks", "yes": "yes", "yesterday": "Yesterday", "you.cannot.create.a.cyclic.group": "You cannot create a cyclic group", @@ -654,7 +667,7 @@ const LocaleTranslationData = { "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.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}", @@ -715,6 +728,14 @@ const LocaleTranslationData = { "custom.value": "Custom value\u{26A1}", "date.range": 'Plage de dates', "day": "Journée", + "day.of.week": "Jour de la Semaine", + "day.sunday": "Dimanche", + "day.monday": "Lundi", + "day.tuesday": "Mardi", + "day.wednesday": "Mercredi", + "day.thursday": "Jeudi", + "day.friday": "Vendredi", + "day.saturday": "Samedi", "days": "Journées", "days.enter": "Enter in days and then hit enter\u{26A1}", "decreasing": "decreasing\u{26A1}", @@ -741,6 +762,7 @@ const LocaleTranslationData = { "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}", @@ -1040,6 +1062,7 @@ 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.map": "Select Map\u{26A1}", "select.meter": "Sélectionnez de Mètres", @@ -1149,6 +1172,9 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics\u{26A1}", "website": "site web", "week": "Semaine", + "week.create": "Créer une Semaine", + "week.edit": "Éditer Semaine", + "weeks": "Semaines", "yes": " yes\u{26A1}", "yesterday": "Hier", "you.cannot.create.a.cyclic.group": "Vous ne pouvez pas créer un groupe cyclique", @@ -1229,7 +1255,7 @@ const LocaleTranslationData = { "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.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", @@ -1290,6 +1316,14 @@ const LocaleTranslationData = { "custom.value": "Valor personalizado", "date.range": 'Rango de fechas', "day": "Día", + "day.of.week": "Día de la Semana", + "day.sunday": "Domingo", + "day.monday": "Lunes", + "day.tuesday": "Martes", + "day.wednesday": "Miércoles", + "day.thursday": "Jueves", + "day.friday": "Viernes", + "day.saturday": "Sábado", "days": "Días", "days.enter": "Ingrese los días y presione \"Enter\"", "decreasing": "decreciente", @@ -1316,6 +1350,7 @@ const LocaleTranslationData = { "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}", @@ -1615,6 +1650,7 @@ 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.map": "Seleccionar mapa", "select.meter": "Seleccionar medidor", @@ -1727,6 +1763,9 @@ const LocaleTranslationData = { "visual.unit": "Units Visual Graphics\u{26A1}", "website": "sitio web", "week": "semana", + "week.create": "Crear una Semana", + "week.edit": "Editar Semana", + "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/weeks.ts b/src/client/app/types/redux/weeks.ts new file mode 100644 index 0000000000..d9056dea66 --- /dev/null +++ b/src/client/app/types/redux/weeks.ts @@ -0,0 +1,26 @@ +/* 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; + name: string; + 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']; +} From 9d2d38a588cc3bb0d414e76e793447cd5461e622 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Fri, 18 Jul 2025 16:03:23 -0600 Subject: [PATCH 006/170] added UI for for daily pattern overview page and create modal --- .../CreateConversionModalComponent.tsx | 44 ++- .../CreateDailyPatternModalComponent.tsx | 360 ++++++++++++++++++ .../daily-pattern/DayDetailComponent.tsx | 6 +- .../app/redux/selectors/adminSelectors.ts | 17 + src/client/app/translations/data.ts | 30 +- 5 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index afc3165b32..f3c8428cc8 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -47,25 +47,25 @@ export default function CreateConversionModalComponent() { // Handlers for each type of input change const [conversionState, setConversionState] = useState({ - overallConversion: { - sourceId: defaultValues.sourceId, - destinationId: defaultValues.destinationId, - bidirectional: defaultValues.bidirectional, - note: defaultValues.overallConversionNote - }, - initialConversion: { - sourceId: defaultValues.sourceId, - destinationId: defaultValues.destinationId, - bidirectional: defaultValues.bidirectional, - slope: defaultValues.slope, - intercept: defaultValues.intercept, - pattern: defaultValues.weeklyPattern, - note: defaultValues.initialConversionNote - }, - sourceOptions: defaultValues.sourceOptions, - destinationOptions: defaultValues.destinationOptions, - weeklyPatterns: defaultValues.weeklyPattern -}); + overallConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + note: defaultValues.overallConversionNote + }, + initialConversion: { + sourceId: defaultValues.sourceId, + destinationId: defaultValues.destinationId, + bidirectional: defaultValues.bidirectional, + slope: defaultValues.slope, + intercept: defaultValues.intercept, + pattern: defaultValues.weeklyPattern, + note: defaultValues.initialConversionNote + }, + sourceOptions: defaultValues.sourceOptions, + destinationOptions: defaultValues.destinationOptions, + weeklyPatterns: defaultValues.weeklyPattern + }); // If the currently selected conversion is valid // TODO: Add a check for the weekly pattern as well. @@ -100,7 +100,7 @@ export default function CreateConversionModalComponent() { } })); } -}; + }; // const handleBooleanChange = (e: React.ChangeEvent) => { // setConversionState({ ...conversionState, [e.target.name]: JSON.parse(e.target.value) }); // }; @@ -429,7 +429,9 @@ export default function CreateConversionModalComponent() { value={conversionState.overallConversion.note} /> {/*Initial conversion*/} -
Initial Conversion
+
+ +
{/* Slope input*/} diff --git a/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx b/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx new file mode 100644 index 0000000000..77c5a4ca83 --- /dev/null +++ b/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx @@ -0,0 +1,360 @@ +/* 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 * as React from 'react'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button, Col, Container, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { selectDefaultCreateDailyPatternValues } from '../../redux/selectors/adminSelectors'; +import '../../styles/modal.css'; +import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { useTranslate } from '../../redux/componentHooks'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +/** + * Defines the create conversion modal form + * @returns Conversion create element + */ +export default function CreateDailyPatternModalComponent() { + const translate = useTranslate(); + //const [addConversionMutation] = conversionsApi.useAddConversionMutation(); + // Want units in sorted order by identifier regardless of case. + + const defaultValues = useAppSelector(selectDefaultCreateDailyPatternValues); + + /* State */ + // Modal show + const [showModal, setShowModal] = useState(false); + + // State for the warning modal + const [showWarningModal, setShowWarningModal] = useState(false); + const [warningMessage, setWarningMessage] = useState(''); + + const handleClose = () => { + setShowModal(false); + resetState(); + }; + const handleShow = () => setShowModal(true); + + // Handlers for each type of input change + const [patternState, setPatternState] = useState({ + dailyPattern: { + name: defaultValues.name, + note: defaultValues.dailyPatternNote + }, + initialPattern: { + slope: defaultValues.slope, + intercept: defaultValues.intercept, + startHour: defaultValues.startHour, + endHour: defaultValues.endHour, + note: defaultValues.initialPatternNote + } +}); + + // TODO: Add a check for the fields in daily pattern + // const [validConversion, reason] = useAppSelector(state => + // selectIsValidConversion(state, { + // sourceId: conversionState.initialConversion.sourceId, + // destinationId: conversionState.initialConversion.destinationId, + // bidirectional: conversionState.initialConversion.bidirectional, + // slope: conversionState.initialConversion.slope, + // intercept: conversionState.initialConversion.intercept, + // //pattern: conversionState.initialConversion.pattern, + // note: conversionState.initialConversion.note + // }) + // ); + + const handleStringChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (name === 'dailyPatternNote') { + setPatternState(prev => ({ + ...prev, + dailyPattern: { + ...prev.dailyPattern, + note: value + } + })); + } else if (name === 'initialPatternNote') { + setPatternState(prev => ({ + ...prev, + initialPattern: { + ...prev.initialPattern, + note: value + } + })); + } + }; + + const handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newValue = Number(value); + console.log(`handleNumberChange: ${name} = ${newValue}`); + + // setConversionState(prev => { + // if (name === 'sourceId') { + // return { + // ...prev, + // dailyPattern: { + // ...prev.dailyPattern, + // sourceId: newValue + // }, + // initialPattern: { + // ...prev.initialPattern, + // sourceId: newValue + // } + // }; + // } else if (name === 'destinationId') { + // return { + // ...prev, + // overallConversion: { + // ...prev.overallConversion, + // destinationId: newValue + // }, + // initialConversion: { + // ...prev.initialConversion, + // destinationId: newValue + // }, + // sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) + // }; + // } else { + // return { + // ...prev, + // initialConversion: { + // ...prev.initialConversion, + // [name]: newValue + // } + // }; + // } + // }); + }; + + /* Warning Modal */ + const handleWarningConfirm = () => { + //Close the warning modal + setShowWarningModal(false); + + // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready + // Create a placeholder payload for the conversion creation until backend is ready + // 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, + // pattern: conversionState.initialConversion.pattern, + // initialNote: conversionState.initialConversion.note + // }; + // console.log( + // 'sourceId:', conversionState.overallConversion.sourceId, typeof conversionState.overallConversion.sourceId, + // 'destinationId:', conversionState.overallConversion.destinationId, typeof conversionState.overallConversion.destinationId + // ); + // addConversionMutation(payload); + resetState(); + }; + + const handleWarningCancel = () => { + //Close the warning modal + setShowWarningModal(false); + }; + + // Reset the state to default values + const resetState = () => { + setPatternState({ + dailyPattern: { + name: defaultValues.name, + note: defaultValues.dailyPatternNote + }, + initialPattern: { + slope: defaultValues.slope, + intercept: defaultValues.intercept, + startHour: defaultValues.startHour, + endHour: defaultValues.endHour, + note: defaultValues.initialPatternNote + } + }); + }; + /* End Warning Modal */ + + // Submit + const handleSubmit = () => { + // Show warning modal if slope and intercept are both 0 + if (patternState.initialPattern.slope === 0 && patternState.initialPattern.intercept === 0) { + setWarningMessage(translate('conversion.slope.intercept.zero')); + setShowWarningModal(true); + } else { + // 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 + + // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready + // Example updated logic for creating a conversion: + // 1. Create overall conversion + //const overallResult = await addConversionMutation(conversionState.overallConversion).unwrap(); + // 2. Create initial conversion with foreign key + // await addConversionMutation({ + // ...conversionState.initialConversion, + // overallConversionId: overallResult.id + // }); + + // Create a placeholder payload for the conversion creation until backend is ready + // 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, + // pattern: conversionState.initialConversion.pattern, + // initialNote: conversionState.initialConversion.note + // }; + // addConversionMutation(payload); + resetState(); + } + }; + + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipCreateConversionView: 'help.admin.conversioncreate' + }; + + return ( + <> + + {/* Show modal button */} + + + + + + +
+ +
+
+ {/* when any of the conversion are changed call one of the functions. */} + + + {/* Name for daily pattern*/} + + + handleStringChange(e)} + value={patternState.dailyPattern.note} /> + + {/* Note input for overall conversion*/} + + + handleStringChange(e)} + value={patternState.dailyPattern.note} /> + + {/*Initial pattern*/} +
+ +
+ + + {/* Slope input*/} + + + handleNumberChange(e)} /> + + + + {/* Intercept input*/} + + + handleNumberChange(e)} /> + + + + + + {/* Start hour input*/} + + + handleNumberChange(e)} /> + + + + {/* End hour input*/} + + + handleNumberChange(e)} /> + + + + {/* Note input for initial pattern*/} + + + handleStringChange(e)} + value={patternState.initialPattern.note} /> + +
+
+ + { + // Todo looks kind of bad make a better visible notification + // !validConversion &&

{reason}

+ } + + {/* Hides the modal */} + + {/* On click calls the function handleSaveChanges in this component */} + +
+
+ + ); +} diff --git a/src/client/app/components/daily-pattern/DayDetailComponent.tsx b/src/client/app/components/daily-pattern/DayDetailComponent.tsx index 2dd477dca8..09f3a14ef0 100644 --- a/src/client/app/components/daily-pattern/DayDetailComponent.tsx +++ b/src/client/app/components/daily-pattern/DayDetailComponent.tsx @@ -8,7 +8,7 @@ import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; import { Day } from '../../types/redux/days'; import DayViewComponent from './DayViewComponent'; -import { Button } from 'reactstrap'; +import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent'; /** * Hardcoded day data for demonstration. @@ -70,9 +70,7 @@ export default function DayDetailComponent() {
{/* Placeholder for create day modal */} - +
{hardcodedDays diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index f076a8517b..4b4d1a92be 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -341,6 +341,23 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); +export const selectDefaultCreateDailyPatternValues = createAppSelector( + [selectAllUnits], + () => { + const defaultValues = { + name: '', + dailyPatternNote: '', + slope: 0, + intercept: 0, + startHour:0, + endHour: 24, + initialPatternNote: '' + }; + return defaultValues; + } +); + + /* Create Meter Validation: Name cannot be blank Area must be positive or zero diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 9857bb4001..ba45a307c7 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -102,9 +102,6 @@ const LocaleTranslationData = { "conversion.pattern": "Pattern:", "conversions": "Conversions", "ConversionType.conversion": "conversion", - "daily.patterns": "Daily Patterns", - "daily.patterns.create": "Create a Daily Pattern", - "daily.patterns.edit": "Details/Edit Daily Pattern", "create.conversion": "Create a Conversion", "create.group": "Create a Group", "create.map": "Create a Map", @@ -138,6 +135,11 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings", "custom.date.range": "Custom date range", "custom.value": "Custom value", + "daily.patterns": "Daily Patterns", + "daily.patterns.create": "Create a Daily Pattern", + "daily.patterns.edit": "Details/Edit Daily Pattern", + "daily.patterns.start.hour": "Start Hour", + "daily.patterns.end.hour": "End Hour", "date.range": 'Date Range', "day": "Day", "days": "Days", @@ -306,6 +308,8 @@ const LocaleTranslationData = { "increasing": "increasing", "info": " for more information. ", "initializing": "Initializing", + "initial.conversion": "Initial Conversion", + "initial.pattern": "Initial Pattern", "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: ", @@ -677,9 +681,6 @@ const LocaleTranslationData = { "conversions": "Conversions\u{26A1}", "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", - "daily.patterns": "Daily Patterns\u{26A1}", - "daily.patterns.create": "Create a Daily Pattern\u{26A1}", - "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", "create.group": "Créer un Groupe", "create.map": "Créer une carte", @@ -713,6 +714,11 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", + "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.start.hour": "Start Hour\u{26A1}", + "daily.patterns.end.hour": "End Hour\u{26A1}", "date.range": 'Plage de dates', "day": "Journée", "days": "Journées", @@ -881,6 +887,8 @@ const LocaleTranslationData = { "increasing": "increasing\u{26A1}", "info": " pour plus d'informations. ", "initializing": "Initializing\u{26A1}", + "initial.conversion": "Initial Conversion\u{26A1}", + "initial.pattern": "Initial Pattern\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}", @@ -1252,9 +1260,6 @@ const LocaleTranslationData = { "conversions": "Conversiones", "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversión", - "daily.patterns": "Daily Patterns\u{26A1}", - "daily.patterns.create": "Create a Daily Pattern\u{26A1}", - "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "create.conversion": "Crear una conversión", "create.group": "Crear un grupo", "create.map": "Crear un mapa", @@ -1288,6 +1293,11 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", + "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.start.hour": "Start Hour\u{26A1}", + "daily.patterns.end.hour": "End Hour\u{26A1}", "date.range": 'Rango de fechas', "day": "Día", "days": "Días", @@ -1456,6 +1466,8 @@ const LocaleTranslationData = { "increasing": "aumentando", "info": " para obtener más información. ", "initializing": "Inicializando", + "initial.conversion": "Initial Conversion\u{26A1}", + "initial.pattern": "Initial Pattern\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: ", From 9f653766c667dc40eb72860760443d1aca06a18b Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Fri, 18 Jul 2025 19:28:19 -0700 Subject: [PATCH 007/170] feat(weeks): add help tooltips to week views; change /weeks page link in navbar to "Patterns - Weekly" --- .../app/components/HeaderButtonsComponent.tsx | 14 +++++++------- .../app/components/TooltipHelpComponent.tsx | 5 ++++- .../weeks/CreateWeekModalComponent.tsx | 11 +++++++++-- .../components/weeks/EditWeekModalComponent.tsx | 7 +++++++ .../components/weeks/WeeksDetailComponent.tsx | 10 +++++++--- src/client/app/redux/selectors/adminSelectors.ts | 16 ++++++++-------- src/client/app/translations/data.ts | 12 ++++++++++++ 7 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index a845b654b4..1d94577f94 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -202,13 +202,6 @@ export default function HeaderButtonsComponent() { to="/dailyPatterns"> - - - + + + + +
+ +
@@ -114,10 +121,10 @@ export default function CreateWeekModalComponent(): React.ReactElement { name={day} value={weekDetails[day]} // TODO (evan-carey): change if the day ID is a number instead of a string - invalid={weekDetails[day] === '-1'} + invalid={weekDetails[day] === '-999'} onChange={event => setWeekDetails({ ...weekDetails, [day]: event.target.value })} > - {/* TODO (evan-carey): populate with real days data */} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx index 7c08097586..4fddd966f5 100644 --- a/src/client/app/components/weeks/EditWeekModalComponent.tsx +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -5,8 +5,11 @@ 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 { tooltipBaseStyle } from '../../styles/modalStyle'; import { Week } from '../../types/redux/weeks'; import translate from '../../utils/translate'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; import { fakeDaysData } from './fake-week-data'; interface EditWeekModalComponentProps { @@ -69,6 +72,10 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp + +
+ +
diff --git a/src/client/app/components/weeks/WeeksDetailComponent.tsx b/src/client/app/components/weeks/WeeksDetailComponent.tsx index d20ed9d1e1..3c60d75cdb 100644 --- a/src/client/app/components/weeks/WeeksDetailComponent.tsx +++ b/src/client/app/components/weeks/WeeksDetailComponent.tsx @@ -4,8 +4,9 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { titleStyle } from '../../styles/modalStyle'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; import CreateWeekModalComponent from './CreateWeekModalComponent'; import { fakeWeeksData } from './fake-week-data'; import WeekViewComponent from './WeekViewComponent'; @@ -23,11 +24,14 @@ export default function WeeksDetailComponent() { return (
- +

- + +
+ +

diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 17e4a504be..6bd68a3130 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -343,16 +343,16 @@ export const selectDefaultCreateConversionValues = createAppSelector( export const selectDefaultCreateWeekValues = createAppSelector( [], () => ({ - id: -1, + id: -99, name: '', note: '', - sunday: '-1', - monday: '-1', - tuesday: '-1', - wednesday: '-1', - thursday: '-1', - friday: '-1', - saturday: '-1' + sunday: '-999', + monday: '-999', + tuesday: '-999', + wednesday: '-999', + thursday: '-999', + friday: '-999', + saturday: '-999' }) ); diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 086063ad32..22f191f07a 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -273,6 +273,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.", @@ -445,6 +448,7 @@ 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.weekly": "Patterns - Weekly", "per.day": "Per Day", "per.hour": "Per Hour", "per.minute": "Per Minute", @@ -861,6 +865,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}", @@ -1033,6 +1040,7 @@ 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.weekly": "Modèles - Hebdomadaires", "per.day": "Per Day\u{26A1}", "per.hour": "Per Hour\u{26A1}", "per.minute": "Per Minute\u{26A1}", @@ -1449,6 +1457,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.", @@ -1621,6 +1632,7 @@ 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.weekly": "Patrones - Semanales", "per.day": "Por día", "per.hour": "Por hora", "per.minute": "Por minuto", From 5691a310a86d509111768a172423ec98a03e6268 Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Fri, 18 Jul 2025 20:46:03 -0700 Subject: [PATCH 008/170] Add fr/es translations for Edit Conversion UI Signed-off-by: PRINCESANCHEZ --- src/client/app/translations/data.ts | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 60490fb536..58b1c3b280 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -89,16 +89,24 @@ const LocaleTranslationData = { "conversion.failed.to.edit.conversion": "Failed to edit conversion.", "conversion.intercept": "Intercept:", "conversion.is.deleted": "Conversion removed from database", + "conversion.noPattern": "No Pattern", + "conversion.pattern": "Pattern", "conversion.represent": "Conversion Represent:", "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.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": "Conversion Table", + "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", "conversions": "Conversions", "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", @@ -158,7 +166,9 @@ 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", @@ -472,12 +482,15 @@ const LocaleTranslationData = { "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", "sort": "Sort Order", + "split.earlier": "Split\u2191", + "split.later": "Split\u2193", "submit": "Submit", "submit.changes": "Submit changes", "submit.new.user": "Submit new user", @@ -659,16 +672,24 @@ const LocaleTranslationData = { "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.noPattern": "Aucun modèle\u{26A1}", + "conversion.pattern": "Modèle\u{26A1}", "conversion.represent": "Conversion Represent:\u{26A1}", "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.prompt": "Entrez la date et l'heure (AAAA-MM-JJ HH:mm:ss) pour diviser le segment:\u{26A1}", "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": "Tableau de conversion\u{26A1}", + "conversion.table.delete.earlier": "Supprimer précédent\u2191\u{26A1}", + "conversion.table.delete.later": "Supprimer suivant\u2193\u{26A1}", + "conversion.table.split.earlier": "Diviser avant\u2191\u{26A1}", + "conversion.table.split.later": "Diviser après\u2193\u{26A1}", "conversions": "Conversions\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", @@ -728,7 +749,9 @@ 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\u{26A1}", "delete.group": "Supprimer le Groupe", + "delete.later": "Supprimer\u2193\u{26A1}", "delete.map": "Supprimer la carte", "delete.self": "Impossible de supprimer votre propre compte.", "delete.user": "Supprimer l'utilisateur", @@ -1042,12 +1065,15 @@ const LocaleTranslationData = { "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\u{26A1}", "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}", "sort": "Sort Order\u{26A1}", + "split.earlier": "Diviser\u2191\u{26A1}", + "split.later": "Diviser\u2193\u{26A1}", "submit": "Soumettre", "submit.changes": "Soumettre les changements", "submit.new.user": "Submit new user\u{26A1}", @@ -1229,16 +1255,24 @@ const LocaleTranslationData = { "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.noPattern": "Sin patrón\u{26A1}", + "conversion.pattern": "Patrón\u{26A1}", "conversion.represent": "La conversión representa:", "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.prompt": "Ingrese la fecha y hora (AAAA-MM-DD HH:mm:ss) para dividir el segmento:\u{26A1}", "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": "Tabla de conversión\u{26A1}", + "conversion.table.delete.earlier": "Eliminar anterior\u2191\u{26A1}", + "conversion.table.delete.later": "Eliminar siguiente\u2193\u{26A1}", + "conversion.table.split.earlier": "Dividir antes\u2191\u{26A1}", + "conversion.table.split.later": "Dividir después\u2193\u{26A1}", "conversions": "Conversiones", "ConversionType.conversion": "conversión", "create.conversion": "Crear una conversión", @@ -1298,7 +1332,9 @@ 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\u{26A1}", "delete.group": "Borrar grupo", + "delete.later": "Eliminar\u2193\u{26A1}", "delete.map": "Borrar mapa", "delete.self": "No puedes eliminar tu propia cuenta.", "delete.user": "Borrar usario", @@ -1612,12 +1648,15 @@ const LocaleTranslationData = { "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\u{26A1}", "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}", "sort": "Sort Order\u{26A1}", + "split.earlier": "Dividir\u2191\u{26A1}", + "split.later": "Dividir\u2193\u{26A1}", "submit": "Enviar", "submit.changes": "Ingresar los cambios", "submit.new.user": "Ingresar un nuevo usario", From 47b3130cbf788c38083490e762f96d5e974d401e Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Tue, 22 Jul 2025 20:09:46 -0700 Subject: [PATCH 009/170] feat(weeks): complete weekly conversion patterns feature * CRUD operations working with back-end * client-side validation * delete confirmation * Spanish and French translations --- .../daily-pattern/DayDetailComponent.tsx | 20 +-- .../daily-pattern/DayViewComponent.tsx | 2 +- .../weeks/CreateWeekModalComponent.tsx | 139 +++++++++++----- .../weeks/EditWeekModalComponent.tsx | 151 +++++++++++++----- .../components/weeks/WeekViewComponent.tsx | 2 +- .../components/weeks/WeeksDetailComponent.tsx | 52 +++--- .../app/components/weeks/fake-week-data.ts | 39 ----- src/client/app/redux/api/baseApi.ts | 3 +- src/client/app/redux/api/daysApi.ts | 131 ++++++++------- src/client/app/redux/api/weeksApi.ts | 40 +++-- .../app/redux/selectors/adminSelectors.ts | 17 +- src/client/app/translations/data.ts | 26 +++ src/client/app/types/redux/days.ts | 6 +- src/client/app/types/redux/weeks.ts | 10 +- 14 files changed, 391 insertions(+), 247 deletions(-) delete mode 100644 src/client/app/components/weeks/fake-week-data.ts diff --git a/src/client/app/components/daily-pattern/DayDetailComponent.tsx b/src/client/app/components/daily-pattern/DayDetailComponent.tsx index 2dd477dca8..c35498ea73 100644 --- a/src/client/app/components/daily-pattern/DayDetailComponent.tsx +++ b/src/client/app/components/daily-pattern/DayDetailComponent.tsx @@ -1,22 +1,22 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import SpinnerComponent from '../SpinnerComponent'; -import TooltipHelpComponent from '../TooltipHelpComponent'; -import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import { Button } from 'reactstrap'; import { useAppSelector } from '../../redux/reduxHooks'; import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; import { Day } from '../../types/redux/days'; +import SpinnerComponent from '../SpinnerComponent'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; import DayViewComponent from './DayViewComponent'; -import { Button } from 'reactstrap'; /** * Hardcoded day data for demonstration. */ const hardcodedDays: Day[] = [ { - id: '1', - name: 'Weekday', + id: 1, + dayName: 'Weekday', note: 'Standard weekday pattern', segments: [ { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, @@ -25,8 +25,8 @@ const hardcodedDays: Day[] = [ ] }, { - id: '2', - name: 'Weekend', + id: 2, + dayName: 'Weekend', note: 'Weekend pattern', segments: [ { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } @@ -76,7 +76,7 @@ export default function DayDetailComponent() {
{hardcodedDays - .sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase(), locale, { sensitivity: 'accent' })) + .sort((a, b) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) .map(day => ( ))} @@ -86,4 +86,4 @@ export default function DayDetailComponent() { )}
); -} \ No newline at end of file +} diff --git a/src/client/app/components/daily-pattern/DayViewComponent.tsx b/src/client/app/components/daily-pattern/DayViewComponent.tsx index 9cd09bb99c..36f1c0d8f1 100644 --- a/src/client/app/components/daily-pattern/DayViewComponent.tsx +++ b/src/client/app/components/daily-pattern/DayViewComponent.tsx @@ -38,7 +38,7 @@ export default function DayViewComponent(props: DayViewComponentProps) { // Create header from sourceId, destinationId identifiers //const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + // unitDataById[props.conversion.destinationId]?.identifier); - const dayIdentifier = props.day.name || 'Unnamed Day'; + const dayIdentifier = props.day.dayName || 'Unnamed Day'; // Unlike the details component, we don't check if units are loaded since must come through that page. diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index 0b7315535c..3f0536d2ab 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -5,61 +5,125 @@ 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 { weeksApi } from '../../redux/api/weeksApi'; 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 translate from '../../utils/translate'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; -import { fakeDaysData } from './fake-week-data'; /** * 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 { - + // State to control modal visibility const [showModal, setShowModal] = React.useState(false); + // Fetch days data + const { data: days, isFetching: isFetchingDays } = daysApi.useGetDaysDetailsQuery(); + + // 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.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + }, [days]); + + // Fetch weeks data (used to check if week name already exists) + const { data: weeks } = weeksApi.useGetWeeksQuery(); + + 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); - // reference to the submit button - const submitButtonRef = React.useRef(null); - - const onToggleModal = () => { - setShowModal(!showModal); - if (showModal) { - resetState(); - } - }; + // Function to toggle the modal visibility + const toggleModal = () => setShowModal(!showModal); + // Function to handle form submission + // Validates the week details and submits them to the API const handleSubmit = () => { - // prevent multiple submissions - submitButtonRef.current?.setAttribute('disabled', 'true'); + if (!isWeekValid) { + return; + } - // TODO (evan-carey): Add logic to save the week details - console.log('Week details submitted:', weekDetails); + addWeekMutation(weekDetails).unwrap() + .then(() => { + showSuccessNotification(translate('week.create.success')); + toggleModal(); + }) + .catch(error => { + showErrorNotification(translate('week.create.failure') + error); + }); }; + // Function to reset the week details to default values const resetState = () => { setWeekDetails(defaultValues); }; - // TODO (evan-carey): Add validation logic - const isValidWeek = true; + // 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(() => { + const trimmedName = weekDetails.weekName.trim(); + if (trimmedName === '') { + setNameValidationMessageId('error.required'); + return false; + } + + if (weeks?.some(week => week.weekName.toLowerCase() === trimmedName.toLowerCase())) { + setNameValidationMessageId('week.validation.name.exists'); + return false; + } + + setNameValidationMessageId(null); + return true; + }, [weekDetails.weekName, 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]); + + // Reference to the name input field to set focus when the modal opens + const nameInputFieldRef = React.useRef(null); return ( <> - {/* Show cerate modal button */} - - - + nameInputFieldRef.current?.focus()} + onClosed={resetState} + > +
@@ -78,11 +142,13 @@ export default function CreateWeekModalComponent(): React.ReactElement { type="text" name="name" required - value={weekDetails.name} - invalid={!weekDetails.name} - onChange={event => setWeekDetails({ ...weekDetails, name: event.target.value })} /> + value={weekDetails.weekName} + invalid={!isWeekNameValid} + onChange={event => setWeekDetails({ ...weekDetails, weekName: event.target.value })} + innerRef={nameInputFieldRef} // Set focus on this field when modal opens + /> - + {nameValidationMessageId && } @@ -120,17 +186,17 @@ export default function CreateWeekModalComponent(): React.ReactElement { type="select" name={day} value={weekDetails[day]} - // TODO (evan-carey): change if the day ID is a number instead of a string - invalid={weekDetails[day] === '-999'} - onChange={event => setWeekDetails({ ...weekDetails, [day]: event.target.value })} + invalid={weekDetails[day] === -999} + disabled={isFetchingDays} + onChange={event => setWeekDetails({ ...weekDetails, [day]: Number(event.target.value) })} > - - {/* TODO (evan-carey): populate with real days data */} - {fakeDaysData.map(fakeDay => ( - ))} @@ -144,14 +210,15 @@ export default function CreateWeekModalComponent(): React.ReactElement { - + {/* Submit button */} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx index 4fddd966f5..d36dc84a37 100644 --- a/src/client/app/components/weeks/EditWeekModalComponent.tsx +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -5,12 +5,16 @@ 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 { weeksApi } from '../../redux/api/weeksApi'; import { tooltipBaseStyle } from '../../styles/modalStyle'; +import { LocaleDataKey } from '../../translations/data'; import { Week } from '../../types/redux/weeks'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; import translate from '../../utils/translate'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; -import { fakeDaysData } from './fake-week-data'; interface EditWeekModalComponentProps { /** @@ -18,6 +22,9 @@ interface EditWeekModalComponentProps { */ show: boolean; + /** + * The week to edit + */ week: Week; /** @@ -33,43 +40,100 @@ interface EditWeekModalComponentProps { */ export default function EditWeekModalComponent(props: EditWeekModalComponentProps): React.ReactElement { - const values = { ...props.week }; + // #region Edit week + // 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.useGetDaysDetailsQuery(); - const [weekDetails, setWeekDetails] = React.useState(values); + // 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.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + }, [days]); - // reference to the submit button - const submitButtonRef = React.useRef(null); + // Fetch weeks data (used to check if week name already exists) + const { data: weeks } = weeksApi.useGetWeeksQuery(); + + const [editWeekMutation, { isLoading: isSaving }] = weeksApi.useEditWeekMutation(); const resetState = () => { - setWeekDetails(values); + setWeekDetails({ ...props.week }); }; - // TODO (evan-carey): Add logic to save changes + // Function to handle form submission. Validates the week details and submits them to the API const handleSubmit = () => { - // prevent multiple submissions - submitButtonRef.current?.setAttribute('disabled', 'true'); - - console.log('Week details submitted:', weekDetails); - - // TODO (evan-carey): Add logic to save the week details, update redux state, close modal, and show success toast + if (!isWeekValid) { + return; + } + + editWeekMutation(weekDetails).unwrap() + .then(() => { + showSuccessNotification(translate('week.edit.success')); + props.handleClose(); + }) + .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(() => { + const trimmedName = weekDetails.weekName.trim(); + if (trimmedName === '') { + setNameValidationMessageId('error.required'); + return false; + } + if (weeks?.some(week => week.weekName.toLowerCase() === trimmedName.toLowerCase() && week.id !== weekDetails.id)) { + setNameValidationMessageId('week.validation.name.exists'); + return false; + } + + setNameValidationMessageId(null); + return true; + }, [weekDetails.weekName, weeks, weekDetails.id]); + + // 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 >= 0 && + weekDetails.monday >= 0 && + weekDetails.tuesday >= 0 && + weekDetails.wednesday >= 0 && + weekDetails.thursday >= 0 && + weekDetails.friday >= 0 && + weekDetails.saturday >= 0; + }, [weekDetails, isWeekNameValid]); + // #endregion Edit week + + // #region Delete week + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [deleteWeekMutation, { isLoading: isDeleting }] = weeksApi.useDeleteWeekMutation(); + + // Function to delete the week const handleDelete = () => { - // TODO (evan-carey): Add logic to delete the week + setShowDeleteModal(false); + deleteWeekMutation({ id: weekDetails.id }).unwrap() + .then(() => { + showSuccessNotification(translate('week.delete.success')); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(translate('week.delete.failure') + error); + }); }; - - const handleClose = () => { - props.handleClose(); - resetState(); - }; - - // TODO (evan-carey): Add proper validation logic? - // For now, just check if the name is not empty - const isValidWeek = weekDetails.name.trim() !== ''; + // #endregion Delete week return ( <> - + @@ -89,11 +153,11 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp type="text" name="name" required - value={weekDetails.name} - invalid={!weekDetails.name} - onChange={event => setWeekDetails({ ...weekDetails, name: event.target.value })} /> + value={weekDetails.weekName} + invalid={!isWeekNameValid} + onChange={event => setWeekDetails({ ...weekDetails, weekName: event.target.value })} /> - + {nameValidationMessageId && } @@ -131,13 +195,13 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp type="select" name={day} value={weekDetails[day]} - invalid={weekDetails[day] === '-1'} - onChange={event => setWeekDetails({ ...weekDetails, [day]: event.target.value })} + invalid={weekDetails[day] < 0} + disabled={isFetchingDays} + onChange={event => setWeekDetails({ ...weekDetails, [day]: Number(event.target.value) })} > - {/* TODO (evan-carey): populate with real days data */} - {fakeDaysData.map(fakeDay => ( - ))} @@ -152,23 +216,34 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp {/* 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 index 49ea99f6cb..bc410a6c94 100644 --- a/src/client/app/components/weeks/WeekViewComponent.tsx +++ b/src/client/app/components/weeks/WeekViewComponent.tsx @@ -25,7 +25,7 @@ export default function WeekViewComponent(props: WeekViewComponentProps): React. return (
-
{week.name}
+
{week.weekName}
{week.note}
diff --git a/src/client/app/components/weeks/WeeksDetailComponent.tsx b/src/client/app/components/weeks/WeeksDetailComponent.tsx index 3c60d75cdb..e639cbc337 100644 --- a/src/client/app/components/weeks/WeeksDetailComponent.tsx +++ b/src/client/app/components/weeks/WeeksDetailComponent.tsx @@ -4,11 +4,12 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; +import { weeksApi } from '../../redux/api/weeksApi'; import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; +import SpinnerComponent from '../SpinnerComponent'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import CreateWeekModalComponent from './CreateWeekModalComponent'; -import { fakeWeeksData } from './fake-week-data'; import WeekViewComponent from './WeekViewComponent'; /** @@ -16,32 +17,43 @@ import WeekViewComponent from './WeekViewComponent'; * @returns Weekly patterns page element */ export default function WeeksDetailComponent() { - // const locale = useAppSelector(selectSelectedLanguage); - // TODO (evan-carey): Replace with real weeks data - const weeks = fakeWeeksData; + const { data: weeks, isFetching } = weeksApi.useGetWeeksQuery(); + + // Sort weeks by week name + const sortedWeeks = React.useMemo(() => { + if (!weeks) { + return []; + } + return [...weeks].sort((a, b) => a.weekName.toLocaleLowerCase().localeCompare(b.weekName.toLocaleLowerCase())); + }, [weeks]); return (
-
- - -
-

- -
- + {isFetching ? ( +
+ + +
+ ) : ( +
+ +
+

+ +
+ +
+

+
+ +
+
+ {sortedWeeks?.map(week => )}
-

-
- -
-
- {/* TODO (evan-carey): sort weeks? */} - {weeks.map(week => )}
-
+ )}
); } diff --git a/src/client/app/components/weeks/fake-week-data.ts b/src/client/app/components/weeks/fake-week-data.ts deleted file mode 100644 index 5c49121022..0000000000 --- a/src/client/app/components/weeks/fake-week-data.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Day } from '../../types/redux/days'; -import { Week } from '../../types/redux/weeks'; - -// ! This file contains fake data for development purposes. -// ! It should be replaced with real data from the backend when available. - -export const fakeDaysData: Day[] = [ - { - id: '1', - name: 'Weekday', - note: 'Standard weekday pattern', - segments: [ - { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, - { id: '1-2', dayId: '1', hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, - { id: '1-3', dayId: '1', hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } - ] - }, - { - id: '2', - name: 'Weekend', - note: 'Weekend pattern', - segments: [ - { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } - ] - } -]; - -export const fakeWeeksData: Week[] = new Array(6).fill(null).map((_, index) => ({ - id: index + 1, - name: `Fake Week Pattern ${index + 1}`, - note: 'This is a fake week pattern used for development purposes', - sunday: fakeDaysData[1].id, - monday: fakeDaysData[0].id, - tuesday: fakeDaysData[0].id, - wednesday: fakeDaysData[0].id, - thursday: fakeDaysData[0].id, - friday: fakeDaysData[0].id, - saturday: fakeDaysData[1].id -})); diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 507afa2a39..103b58eec8 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -36,7 +36,8 @@ export const baseApi = createApi({ 'Units', 'Cik', 'Readings', - 'Weeks' + 'Weeks', + 'DayDetails' ], // Initially no defined endpoints, Use rtk query's injectEndpoints endpoints: () => ({}) diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 01eb336f75..845a8fac77 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -1,73 +1,72 @@ -//import { createSelector } from '@reduxjs/toolkit'; import { Day, DaySegment } from '../../types/redux/days'; -//import { baseApi } from './baseApi'; +import { baseApi } from './baseApi'; // Inject endpoints into baseApi for consistency with conversionsApi -// export const daysApi = baseApi.injectEndpoints({ -// endpoints: builder => ({ -// getDaysDetails: builder.query({ -// query: () => 'api/days', -// providesTags: ['DayDetails'] -// }), -// getDayById: builder.query({ -// query: id => `api/days/${id}`, -// providesTags: (result, error, id) => [{ type: 'DayDetails', id }] -// }), -// addDay: builder.mutation>({ -// query: newDay => ({ -// url: 'api/days', -// method: 'POST', -// body: newDay -// }), -// invalidatesTags: ['DayDetails'] -// }), -// editDay: builder.mutation & Pick>({ -// query: ({ id, ...updatedDay }) => ({ -// url: `api/days/${id}`, -// method: 'PUT', -// body: updatedDay -// }), -// invalidatesTags: ['DayDetails'] -// }), -// deleteDay: builder.mutation({ -// query: id => ({ -// url: `api/days/${id}`, -// method: 'DELETE' -// }), -// invalidatesTags: ['DayDetails'] -// }), -// getDaySegments: builder.query({ -// query: dayId => `api/days/${dayId}/segments`, -// providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] -// }), -// addDaySegment: builder.mutation }>({ -// query: ({ dayId, segment }) => ({ -// url: `api/days/${dayId}/segments`, -// method: 'POST', -// body: segment -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }), -// editDaySegment: builder.mutation }>({ -// query: ({ dayId, segmentId, segment }) => ({ -// url: `api/days/${dayId}/segments/${segmentId}`, -// method: 'PUT', -// body: segment -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }), -// deleteDaySegment: builder.mutation({ -// query: ({ dayId, segmentId }) => ({ -// url: `api/days/${dayId}/segments/${segmentId}`, -// method: 'DELETE' -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }) -// }) -// }); +export const daysApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getDaysDetails: builder.query({ + query: () => 'api/days', + providesTags: ['DayDetails'] + }) + // getDayById: builder.query({ + // query: id => `api/days/${id}`, + // providesTags: (result, error, id) => [{ type: 'DayDetails', id }] + // }), + // addDay: builder.mutation>({ + // query: newDay => ({ + // url: 'api/days', + // method: 'POST', + // body: newDay + // }), + // invalidatesTags: ['DayDetails'] + // }), + // editDay: builder.mutation & Pick>({ + // query: ({ id, ...updatedDay }) => ({ + // url: `api/days/${id}`, + // method: 'PUT', + // body: updatedDay + // }), + // invalidatesTags: ['DayDetails'] + // }), + // deleteDay: builder.mutation({ + // query: id => ({ + // url: `api/days/${id}`, + // method: 'DELETE' + // }), + // invalidatesTags: ['DayDetails'] + // }), + // getDaySegments: builder.query({ + // query: dayId => `api/days/${dayId}/segments`, + // providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] + // }), + // addDaySegment: builder.mutation }>({ + // query: ({ dayId, segment }) => ({ + // url: `api/days/${dayId}/segments`, + // method: 'POST', + // body: segment + // }), + // invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] + // }), + // editDaySegment: builder.mutation }>({ + // query: ({ dayId, segmentId, segment }) => ({ + // url: `api/days/${dayId}/segments/${segmentId}`, + // method: 'PUT', + // body: segment + // }), + // invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] + // }), + // deleteDaySegment: builder.mutation({ + // query: ({ dayId, segmentId }) => ({ + // url: `api/days/${dayId}/segments/${segmentId}`, + // method: 'DELETE' + // }), + // invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] + // }) + }) +}); // Selectors for days -//export const selectDaysQueryState = daysApi.endpoints.getDaysDetails.select(); +// export const selectDaysQueryState = daysApi.endpoints.getDaysDetails.select(); // export const selectDaysDetails = createSelector( // selectDaysQueryState, // ({ data: daysData = [] }) => daysData @@ -86,4 +85,4 @@ export const stableEmptyDaySegments: DaySegment[] = []; // useAddDaySegmentMutation, // useEditDaySegmentMutation, // useDeleteDaySegmentMutation -// } \ No newline at end of file +// } diff --git a/src/client/app/redux/api/weeksApi.ts b/src/client/app/redux/api/weeksApi.ts index f6a63a9ffd..853bb05cf3 100644 --- a/src/client/app/redux/api/weeksApi.ts +++ b/src/client/app/redux/api/weeksApi.ts @@ -6,8 +6,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { Week } from '../../types/redux/weeks'; import { baseApi } from './baseApi'; -// NOTE (evan-carey): This file is a WIP, as the backend API for weeks is not yet implemented. - /** * This file defines the weeksApi using RTK Query. * It provides endpoints for fetching, adding, deleting, and editing weekly patterns. @@ -17,46 +15,44 @@ export const weeksApi = baseApi.injectEndpoints({ endpoints: builder => ({ getWeeks: builder.query({ query: () => 'api/weeks', - providesTags: ['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', + url: 'api/weeks/add', method: 'POST', body: week }), - onQueryStarted: async (_arg, api) => { - api.queryFulfilled - .then(() => { - api.dispatch(weeksApi.endpoints.getWeeks.initiate()); - }); - } + 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 }), - onQueryStarted: async (_, { queryFulfilled, dispatch }) => { - queryFulfilled - .then(() => { - dispatch(weeksApi.endpoints.getWeeks.initiate()); - }); - } + 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 }), - onQueryStarted: async (_arg, api) => { - api.queryFulfilled - .then(() => { - api.dispatch(weeksApi.endpoints.getWeeks.initiate()); - }); - } + 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 6bd68a3130..2ce4fde136 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -339,20 +339,19 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); -// TODO (evan-carey): if Day ID is a number instead of a string, fix this. export const selectDefaultCreateWeekValues = createAppSelector( [], () => ({ id: -99, - name: '', + weekName: '', note: '', - sunday: '-999', - monday: '-999', - tuesday: '-999', - wednesday: '-999', - thursday: '-999', - friday: '-999', - saturday: '-999' + sunday: -999, + monday: -999, + tuesday: -999, + wednesday: -999, + thursday: -999, + friday: -999, + saturday: -999 }) ); diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 22f191f07a..061f59d359 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -592,7 +592,16 @@ const LocaleTranslationData = { "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", @@ -1181,7 +1190,16 @@ const LocaleTranslationData = { "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", @@ -1776,7 +1794,15 @@ const LocaleTranslationData = { "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.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", diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index 240071581d..cb37d2d712 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,6 +1,6 @@ export interface Day { - id: string; - name: string; + id: number; + dayName: string; note: string; segments: DaySegment[]; } @@ -32,4 +32,4 @@ export interface UpdateDaySegmentPayload { export interface DeleteDaySegmentPayload { id: string; -} \ No newline at end of file +} diff --git a/src/client/app/types/redux/weeks.ts b/src/client/app/types/redux/weeks.ts index d9056dea66..6a18af17de 100644 --- a/src/client/app/types/redux/weeks.ts +++ b/src/client/app/types/redux/weeks.ts @@ -10,7 +10,15 @@ import { Day } from './days'; */ export interface Week { id: number; - name: string; + + /** + * The name of the week + */ + weekName: string; + + /** + * An optional note for the week + */ note: string; /** From 8835272110eebbefabcb9b2d2c73d9b1f55ddcdd Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Tue, 22 Jul 2025 20:28:17 -0700 Subject: [PATCH 010/170] fix(weeks): create week API request fails because `id` is included Omit `id` from the default week values model --- src/client/app/components/weeks/CreateWeekModalComponent.tsx | 3 ++- src/client/app/redux/api/weeksApi.ts | 2 +- src/client/app/redux/selectors/adminSelectors.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index 3f0536d2ab..b83bd84577 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -11,6 +11,7 @@ import { useAppSelector } from '../../redux/reduxHooks'; import { selectDefaultCreateWeekValues } from '../../redux/selectors/adminSelectors'; import { tooltipBaseStyle } from '../../styles/modalStyle'; import { LocaleDataKey } from '../../translations/data'; +import { Week } from '../../types/redux/weeks'; import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; import translate from '../../utils/translate'; import TooltipHelpComponent from '../TooltipHelpComponent'; @@ -44,7 +45,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { const defaultValues = useAppSelector(selectDefaultCreateWeekValues); // State to hold the week details being created. Initialized with default values. - const [weekDetails, setWeekDetails] = React.useState(defaultValues); + const [weekDetails, setWeekDetails] = React.useState>(defaultValues); // Function to toggle the modal visibility const toggleModal = () => setShowModal(!showModal); diff --git a/src/client/app/redux/api/weeksApi.ts b/src/client/app/redux/api/weeksApi.ts index 853bb05cf3..9c7db6d680 100644 --- a/src/client/app/redux/api/weeksApi.ts +++ b/src/client/app/redux/api/weeksApi.ts @@ -24,7 +24,7 @@ export const weeksApi = baseApi.injectEndpoints({ [{ type: 'Weeks', id: 'LIST' }] }), - addWeek: builder.mutation({ + addWeek: builder.mutation>({ query: week => ({ url: 'api/weeks/add', method: 'POST', diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 2ce4fde136..1048de4ea0 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -11,6 +11,7 @@ import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { ConversionData } from '../../types/redux/conversions'; import { MeterData, MeterTimeSortType } from '../../types/redux/meters'; 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 { MAX_VAL, MIN_VAL, noUnitTranslated, potentialGraphicUnits } from '../../utils/input'; @@ -339,10 +340,9 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); -export const selectDefaultCreateWeekValues = createAppSelector( +export const selectDefaultCreateWeekValues = createAppSelector<[], Omit>( [], () => ({ - id: -99, weekName: '', note: '', sunday: -999, From dbfd0462ad7c27ef82d6f1669daa49d754c516eb Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Tue, 22 Jul 2025 20:45:46 -0700 Subject: [PATCH 011/170] revert changes to gitignore and docker-compose docker-compose should not be ignored and should not have to be altered for our time-varying conversion work --- .gitignore | 3 --- docker-compose.yml | 9 ++++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index dd7914a64a..0910e89c44 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,3 @@ src/server/tmp/* #ui-testing screenshots src/cypress/screenshots/* - -#docker -docker-compose.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3127b5fc7b..d03dad2338 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,14 +15,14 @@ services: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. volumes: - - pgdata:/var/lib/postgresql/data/pgdata + - ./postgres-data:/var/lib/postgresql/data/pgdata healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 10s retries: 3 - # ports: - # - "5432:5432" + # ports: + # - "5432:5432" # Uncomment the above lines to enable access to the PostgreSQL server # from the host machine. # Web service runs Node @@ -111,5 +111,4 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" -volumes: - pgdata: \ No newline at end of file + \ No newline at end of file From 63deb77ac2563096a0e2cf6201c9ff6af5668999 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Tue, 22 Jul 2025 20:53:13 -0700 Subject: [PATCH 012/170] fix(database): weeks table must be created before conversion_segments table --- src/server/models/database.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/models/database.js b/src/server/models/database.js index c2d4dbcce5..1dbf403f23 100644 --- a/src/server/models/database.js +++ b/src/server/models/database.js @@ -98,10 +98,10 @@ async function createSchema(conn) { await Unit.createUnitRepresentTypesEnum(conn); await Unit.createTable(conn); await Conversion.createTable(conn); - await ConversionSegment.createTable(conn); await Day.createTable(conn); await DaySegment.createTable(conn); await Week.createTable(conn); + await ConversionSegment.createTable(conn); await Cik.createTable(conn); await Meter.createMeterTypesEnum(conn); // This sql code creates a function to check meter's timezone. From 6eccbe39815a35a0337a42dac74626f3193f80c6 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Wed, 23 Jul 2025 12:27:04 -0600 Subject: [PATCH 013/170] created UI for day overview page and create day modal --- .../conversion/ConversionViewComponent.tsx | 6 - .../CreateConversionModalComponent.tsx | 6 +- .../CreateDailyPatternModalComponent.tsx | 177 +++++++----------- .../daily-pattern/DayDetailComponent.tsx | 12 +- src/client/app/redux/api/baseApi.ts | 2 + src/client/app/redux/api/daySegmentsApi.ts | 61 ++++++ src/client/app/redux/api/daysApi.ts | 136 +++++--------- .../app/redux/selectors/adminSelectors.ts | 11 ++ src/client/app/translations/data.ts | 21 ++- src/client/app/types/redux/days.ts | 6 +- 10 files changed, 221 insertions(+), 217 deletions(-) create mode 100644 src/client/app/redux/api/daySegmentsApi.ts 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 f3c8428cc8..44577cb2f7 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -226,10 +226,6 @@ export default function CreateConversionModalComponent() { pattern: conversionState.initialConversion.pattern, initialNote: conversionState.initialConversion.note }; - console.log( - 'sourceId:', conversionState.overallConversion.sourceId, typeof conversionState.overallConversion.sourceId, - 'destinationId:', conversionState.overallConversion.destinationId, typeof conversionState.overallConversion.destinationId - ); addConversionMutation(payload); resetState(); }; @@ -474,7 +470,7 @@ export default function CreateConversionModalComponent() { {defaultValues.weeklyPatterns?.map(p => ( ))} */} - + diff --git a/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx b/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx index 77c5a4ca83..7739218b52 100644 --- a/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx +++ b/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx @@ -6,23 +6,24 @@ import * as React from 'react'; import { useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { Button, Col, Container, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; +import { Button, Col, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; import TooltipHelpComponent from '../TooltipHelpComponent'; import { useAppSelector } from '../../redux/reduxHooks'; -import { selectDefaultCreateDailyPatternValues } from '../../redux/selectors/adminSelectors'; +import { selectDefaultCreateDailyPatternValues, selectIsValidCreateDailyPattern } from '../../redux/selectors/adminSelectors'; import '../../styles/modal.css'; import { tooltipBaseStyle } from '../../styles/modalStyle'; import { useTranslate } from '../../redux/componentHooks'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; +import { daysApi } from '../../redux/api/daysApi'; + /** * Defines the create conversion modal form * @returns Conversion create element */ export default function CreateDailyPatternModalComponent() { const translate = useTranslate(); - //const [addConversionMutation] = conversionsApi.useAddConversionMutation(); - // Want units in sorted order by identifier regardless of case. + const [addDailyPatternMutation] = daysApi.useAddDailyPatternMutation(); const defaultValues = useAppSelector(selectDefaultCreateDailyPatternValues); @@ -55,18 +56,13 @@ export default function CreateDailyPatternModalComponent() { } }); - // TODO: Add a check for the fields in daily pattern - // const [validConversion, reason] = useAppSelector(state => - // selectIsValidConversion(state, { - // sourceId: conversionState.initialConversion.sourceId, - // destinationId: conversionState.initialConversion.destinationId, - // bidirectional: conversionState.initialConversion.bidirectional, - // slope: conversionState.initialConversion.slope, - // intercept: conversionState.initialConversion.intercept, - // //pattern: conversionState.initialConversion.pattern, - // note: conversionState.initialConversion.note - // }) - // ); + // Check if the daily pattern is valid + const [isValidDailyPattern, reason] = useAppSelector(state => + selectIsValidCreateDailyPattern(state, { + name: patternState.dailyPattern.name + // You can add more fields if your selector uses them + }) + ); const handleStringChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -86,74 +82,51 @@ export default function CreateDailyPatternModalComponent() { note: value } })); - } + } else { + setPatternState(prev => ({ + ...prev, + dailyPattern: { + ...prev.dailyPattern, + name: value + } + })); + } }; const handleNumberChange = (e: React.ChangeEvent) => { const { name, value } = e.target; const newValue = Number(value); - console.log(`handleNumberChange: ${name} = ${newValue}`); - - // setConversionState(prev => { - // if (name === 'sourceId') { - // return { - // ...prev, - // dailyPattern: { - // ...prev.dailyPattern, - // sourceId: newValue - // }, - // initialPattern: { - // ...prev.initialPattern, - // sourceId: newValue - // } - // }; - // } else if (name === 'destinationId') { - // return { - // ...prev, - // overallConversion: { - // ...prev.overallConversion, - // destinationId: newValue - // }, - // initialConversion: { - // ...prev.initialConversion, - // destinationId: newValue - // }, - // sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) - // }; - // } else { - // return { - // ...prev, - // initialConversion: { - // ...prev.initialConversion, - // [name]: newValue - // } - // }; - // } - // }); + if (name === 'slope') { + setPatternState(prev => ({ + ...prev, + initialPattern: { + ...prev.initialPattern, + slope: newValue + } + })); + } else if (name === 'intercept') { + setPatternState(prev => ({ + ...prev, + initialPattern: { + ...prev.initialPattern, + intercept: newValue + } + })); + } }; /* Warning Modal */ const handleWarningConfirm = () => { //Close the warning modal setShowWarningModal(false); - - // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready - // Create a placeholder payload for the conversion creation until backend is ready - // 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, - // pattern: conversionState.initialConversion.pattern, - // initialNote: conversionState.initialConversion.note - // }; - // console.log( - // 'sourceId:', conversionState.overallConversion.sourceId, typeof conversionState.overallConversion.sourceId, - // 'destinationId:', conversionState.overallConversion.destinationId, typeof conversionState.overallConversion.destinationId - // ); - // addConversionMutation(payload); + // Add the new pattern and update the store + addDailyPatternMutation({ + dayName: patternState.dailyPattern.name, + slope: patternState.initialPattern.slope, + intercept: patternState.initialPattern.intercept, + note: patternState.dailyPattern.note + }); + // Reset the state to default values resetState(); }; @@ -184,38 +157,19 @@ export default function CreateDailyPatternModalComponent() { const handleSubmit = () => { // Show warning modal if slope and intercept are both 0 if (patternState.initialPattern.slope === 0 && patternState.initialPattern.intercept === 0) { - setWarningMessage(translate('conversion.slope.intercept.zero')); + setWarningMessage(translate('daily.patterns.slope.intercept.zero')); setShowWarningModal(true); } else { // 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 - - // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready - // Example updated logic for creating a conversion: - // 1. Create overall conversion - //const overallResult = await addConversionMutation(conversionState.overallConversion).unwrap(); - // 2. Create initial conversion with foreign key - // await addConversionMutation({ - // ...conversionState.initialConversion, - // overallConversionId: overallResult.id - // }); - - // Create a placeholder payload for the conversion creation until backend is ready - // 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, - // pattern: conversionState.initialConversion.pattern, - // initialNote: conversionState.initialConversion.note - // }; - // addConversionMutation(payload); + // Add the new pattern and update the store + addDailyPatternMutation({ + dayName: patternState.dailyPattern.name, + slope: patternState.initialPattern.slope, + intercept: patternState.initialPattern.intercept, + note: patternState.dailyPattern.note + }); + // Reset the state to default values resetState(); } }; @@ -259,7 +213,13 @@ export default function CreateDailyPatternModalComponent() { name='dailyPatternName' type='text' onChange={e => handleStringChange(e)} - value={patternState.dailyPattern.note} /> + value={patternState.dailyPattern.name} + invalid={!patternState.dailyPattern.name || patternState.dailyPattern.name.trim() === ''} + required + /> + + + {/* Note input for overall conversion*/} @@ -311,7 +271,9 @@ export default function CreateDailyPatternModalComponent() { name='startHour' type='number' value={patternState.initialPattern.startHour} - onChange={e => handleNumberChange(e)} /> + disabled + readOnly + /> @@ -323,7 +285,9 @@ export default function CreateDailyPatternModalComponent() { name='endHour' type='number' value={patternState.initialPattern.endHour} - onChange={e => handleNumberChange(e)} /> + disabled + readOnly + /> @@ -342,15 +306,14 @@ export default function CreateDailyPatternModalComponent() { { // Todo looks kind of bad make a better visible notification - // !validConversion &&

{reason}

+ !isValidDailyPattern &&

{reason}

} - {/* Hides the modal */} {/* On click calls the function handleSaveChanges in this component */} -
diff --git a/src/client/app/components/daily-pattern/DayDetailComponent.tsx b/src/client/app/components/daily-pattern/DayDetailComponent.tsx index 09f3a14ef0..63ba1ec66a 100644 --- a/src/client/app/components/daily-pattern/DayDetailComponent.tsx +++ b/src/client/app/components/daily-pattern/DayDetailComponent.tsx @@ -15,21 +15,21 @@ import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent */ const hardcodedDays: Day[] = [ { - id: '1', + id: 1, name: 'Weekday', note: 'Standard weekday pattern', segments: [ - { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, - { id: '1-2', dayId: '1', hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, - { id: '1-3', dayId: '1', hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } + { id: 1, dayId: 1, hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, + { id: 2, dayId: 1, hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, + { id: 3, dayId: 1, hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } ] }, { - id: '2', + id: 2, name: 'Weekend', note: 'Weekend pattern', segments: [ - { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } + { id: 1, dayId: 2, hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } ] } ]; diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 58d58b5e14..c72b8812be 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -33,6 +33,8 @@ export const baseApi = createApi({ 'Preferences', 'Users', 'ConversionDetails', + 'DailyPattern', + 'DailyPatternSegment', 'Units', 'Cik', 'Readings' diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts new file mode 100644 index 0000000000..3f0e50040d --- /dev/null +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -0,0 +1,61 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { baseApi } from './baseApi'; +import { DaySegment } from '../../types/redux/days'; // Adjust path as needed + +export const daySegmentsApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getDailyPatternSegments: builder.query({ + query: () => 'api/daySegments', + providesTags: ['DailyPatternSegment'] + }), + getDailyPatternSegmentById: builder.query({ + query: id => `api/daySegments/${id}`, + providesTags: (result, error, id) => [{ type: 'DailyPatternSegment', id }] + }), + getDailyPatternSegmentsByDayId: builder.query({ + query: dayId => `api/daySegments/dayId/${dayId}`, + providesTags: (result, error, dayId) => [{ type: 'DailyPatternSegment', dayId }] + }), + addDailyPatternSegment: builder.mutation>({ + query: daySegment => ({ + url: 'api/daySegments/add', + method: 'POST', + body: daySegment + }), + invalidatesTags: ['DailyPatternSegment'] + }), + editDailyPatternSegment: builder.mutation({ + query: daySegment => ({ + url: 'api/daySegments/edit', + method: 'POST', + body: daySegment + }), + invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] + }), + deleteDailyPatternSegment: builder.mutation({ + query: ({ id }) => ({ + url: 'api/daySegments/delete', + method: 'POST', + body: { id } + }), + invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] + }) + }) +}); + +export const selectDaySegmentsQueryState = daySegmentsApi.endpoints.getDailyPatternSegments.select(); +export const selectDaySegments = createSelector( + selectDaySegmentsQueryState, + ({ data: daySegments = [] }) => daySegments +); + +export const stableEmptyDaySegments: DaySegment[] = []; + +export const { + useGetDailyPatternSegmentsQuery, + useGetDailyPatternSegmentByIdQuery, + useGetDailyPatternSegmentsByDayIdQuery, + useAddDailyPatternSegmentMutation, + useEditDailyPatternSegmentMutation, + useDeleteDailyPatternSegmentMutation +} = daySegmentsApi; \ No newline at end of file diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 01eb336f75..4e5c2ccfba 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -1,89 +1,57 @@ -//import { createSelector } from '@reduxjs/toolkit'; -import { Day, DaySegment } from '../../types/redux/days'; -//import { baseApi } from './baseApi'; +import { createSelector } from '@reduxjs/toolkit'; +import { baseApi } from './baseApi'; +import { Day } from '../../types/redux/days'; -// Inject endpoints into baseApi for consistency with conversionsApi -// export const daysApi = baseApi.injectEndpoints({ -// endpoints: builder => ({ -// getDaysDetails: builder.query({ -// query: () => 'api/days', -// providesTags: ['DayDetails'] -// }), -// getDayById: builder.query({ -// query: id => `api/days/${id}`, -// providesTags: (result, error, id) => [{ type: 'DayDetails', id }] -// }), -// addDay: builder.mutation>({ -// query: newDay => ({ -// url: 'api/days', -// method: 'POST', -// body: newDay -// }), -// invalidatesTags: ['DayDetails'] -// }), -// editDay: builder.mutation & Pick>({ -// query: ({ id, ...updatedDay }) => ({ -// url: `api/days/${id}`, -// method: 'PUT', -// body: updatedDay -// }), -// invalidatesTags: ['DayDetails'] -// }), -// deleteDay: builder.mutation({ -// query: id => ({ -// url: `api/days/${id}`, -// method: 'DELETE' -// }), -// invalidatesTags: ['DayDetails'] -// }), -// getDaySegments: builder.query({ -// query: dayId => `api/days/${dayId}/segments`, -// providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] -// }), -// addDaySegment: builder.mutation }>({ -// query: ({ dayId, segment }) => ({ -// url: `api/days/${dayId}/segments`, -// method: 'POST', -// body: segment -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }), -// editDaySegment: builder.mutation }>({ -// query: ({ dayId, segmentId, segment }) => ({ -// url: `api/days/${dayId}/segments/${segmentId}`, -// method: 'PUT', -// body: segment -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }), -// deleteDaySegment: builder.mutation({ -// query: ({ dayId, segmentId }) => ({ -// url: `api/days/${dayId}/segments/${segmentId}`, -// method: 'DELETE' -// }), -// invalidatesTags: (result, error, { dayId }) => [{ type: 'DaySegments', dayId }] -// }) -// }) -// }); +// Tag type for cache invalidation +export const daysApi = baseApi.injectEndpoints({ + endpoints: builder => ({ + getDailyPatterns: builder.query({ + query: () => 'api/days', + providesTags: ['DailyPattern'] + }), + getDailyPatternById: builder.query({ + query: id => `api/days/${id}`, + providesTags: (result, error, id) => [{ type: 'DailyPattern', id }] + }), + addDailyPattern: builder.mutation({ + query: dailyPattern => ({ + url: 'api/days/add', + method: 'POST', + body: dailyPattern + }), + invalidatesTags: ['DailyPattern'] + }), + editDailyPattern: builder.mutation({ + query: dailyPattern => ({ + url: 'api/days/edit', + method: 'POST', + body: dailyPattern + }), + invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + }), + deleteDailyPattern: builder.mutation({ + query: ({ id }) => ({ + url: 'api/days/delete', + method: 'POST', + body: { id } + }), + invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + }) + }) +}); -// Selectors for days -//export const selectDaysQueryState = daysApi.endpoints.getDaysDetails.select(); -// export const selectDaysDetails = createSelector( -// selectDaysQueryState, -// ({ data: daysData = [] }) => daysData -// ); +export const selectDaysQueryState = daysApi.endpoints.getDailyPatterns.select(); +export const selectDays = createSelector( + selectDaysQueryState, + ({ data: days = [] }) => days +); export const stableEmptyDays: Day[] = []; -export const stableEmptyDaySegments: DaySegment[] = []; -// export const { -// useGetDaysDetailsQuery, -// useGetDayByIdQuery, -// useAddDayMutation, -// useEditDayMutation, -// useDeleteDayMutation, -// useGetDaySegmentsQuery, -// useAddDaySegmentMutation, -// useEditDaySegmentMutation, -// useDeleteDaySegmentMutation -// } \ No newline at end of file +export const { + useGetDailyPatternsQuery, + useGetDailyPatternByIdQuery, + useAddDailyPatternMutation, + useEditDailyPatternMutation, + useDeleteDailyPatternMutation +} = daysApi; \ No newline at end of file diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 4b4d1a92be..56ae10b002 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -408,3 +408,14 @@ export const isValidCreateMeter = createAppSelector( } ); +export const selectIsValidCreateDailyPattern = createAppSelector( + [ + (_state, dailyPattern: { name: string }) => dailyPattern + ], + (dailyPattern): [boolean, string] => { + if (!dailyPattern.name || dailyPattern.name.trim() === '') { + return [false, translate('daily.patterns.create.name.required')]; + } + return [true, 'Daily Pattern is Valid']; + } +); diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index ba45a307c7..baa1976a8b 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -89,6 +89,8 @@ const LocaleTranslationData = { "conversion.failed.to.edit.conversion": "Failed to edit conversion.", "conversion.intercept": "Intercept:", "conversion.is.deleted": "Conversion removed from database", + "conversion.pattern": "Pattern:", + "conversion.pattern.no": "No Pattern", "conversion.represent": "Conversion Represent:", "conversion.select.destination": "Select a destination unit", "conversion.select.source": "Select a source unit", @@ -99,7 +101,6 @@ const LocaleTranslationData = { "conversion.successfully.create.conversion": "Successfully created a conversion.", "conversion.successfully.delete.conversion": "Successfully deleted conversion.", "conversion.successfully.edited.conversion": "Successfully edited conversion.", - "conversion.pattern": "Pattern:", "conversions": "Conversions", "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", @@ -135,9 +136,11 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings", "custom.date.range": "Custom date range", "custom.value": "Custom value", - "daily.patterns": "Daily Patterns", + "daily.patterns": "Patterns - Daily", "daily.patterns.create": "Create a Daily Pattern", + "daily.patterns.create.name.required": "Daily pattern name is required", "daily.patterns.edit": "Details/Edit Daily Pattern", + "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?", "daily.patterns.start.hour": "Start Hour", "daily.patterns.end.hour": "End Hour", "date.range": 'Date Range', @@ -668,6 +671,8 @@ const LocaleTranslationData = { "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.pattern": "Pattern:\u{26A1}", + "conversion.pattern.no": "No Pattern\u{26A1}", "conversion.represent": "Conversion Represent:\u{26A1}", "conversion.select.destination": "Select a destination unit\u{26A1}", "conversion.select.source": "Select a source unit\u{26A1}", @@ -679,7 +684,6 @@ const LocaleTranslationData = { "conversion.successfully.delete.conversion": "Successfully deleted conversion.\u{26A1}", "conversion.successfully.edited.conversion": "Successfully edited conversion.\u{26A1}", "conversions": "Conversions\u{26A1}", - "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", "create.group": "Créer un Groupe", @@ -714,9 +718,11 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", - "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns": "Patterns - Daily\u{26A1}", "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", "date.range": 'Plage de dates', @@ -1247,6 +1253,8 @@ const LocaleTranslationData = { "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.pattern": "Pattern:\u{26A1}", + "conversion.pattern.no": "No Pattern\u{26A1}", "conversion.represent": "La conversión representa:", "conversion.select.destination": "Seleccionar una unidad de destinación", "conversion.select.source": "Seleccionar una unidad de fuente", @@ -1258,7 +1266,6 @@ const LocaleTranslationData = { "conversion.successfully.delete.conversion": "Se eliminó una conversión con éxito.", "conversion.successfully.edited.conversion": "Se editó una conversión con éxito", "conversions": "Conversiones", - "conversion.pattern": "Pattern:\u{26A1}", "ConversionType.conversion": "conversión", "create.conversion": "Crear una conversión", "create.group": "Crear un grupo", @@ -1293,9 +1300,11 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", - "daily.patterns": "Daily Patterns\u{26A1}", + "daily.patterns": "Patterns - Daily\u{26A1}", "daily.patterns.create": "Create a Daily Pattern\u{26A1}", + "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", "date.range": 'Rango de fechas', diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index 240071581d..444fff14f9 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,13 +1,13 @@ export interface Day { - id: string; + id: number; name: string; note: string; segments: DaySegment[]; } export interface DaySegment { - id: string; - dayId: string; + id: number; + dayId: number; hour: number; slope: number; intercept: number; From 70f0da55d3cb34d81e748daece231d74578955e7 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Wed, 23 Jul 2025 16:58:15 -0600 Subject: [PATCH 014/170] hooked up day to backend --- src/client/app/components/RouteComponent.tsx | 2 +- .../CreateDailyPatternModalComponent.tsx | 22 ++++++----- .../DayDetailComponent.tsx | 37 ++++--------------- .../DayViewComponent.tsx | 2 +- src/client/app/redux/api/daysApi.ts | 2 +- .../app/redux/selectors/adminSelectors.ts | 6 +-- src/client/app/types/redux/days.ts | 2 +- src/server/models/database.js | 4 +- src/server/util/insertData.js | 15 +++++++- 9 files changed, 42 insertions(+), 50 deletions(-) rename src/client/app/components/{daily-pattern => day}/CreateDailyPatternModalComponent.tsx (94%) rename src/client/app/components/{daily-pattern => day}/DayDetailComponent.tsx (68%) rename src/client/app/components/{daily-pattern => day}/DayViewComponent.tsx (97%) diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index 2634850c6f..5a117a8768 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -27,7 +27,7 @@ import ErrorComponent from './router/ErrorComponent'; import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; import LogMsgComponent from './admin/LogMsgComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; -import DayDetailComponent from './daily-pattern/DayDetailComponent'; +import DayDetailComponent from './day/DayDetailComponent'; /** * @returns the router component Responsible for client side routing. diff --git a/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx b/src/client/app/components/day/CreateDailyPatternModalComponent.tsx similarity index 94% rename from src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx rename to src/client/app/components/day/CreateDailyPatternModalComponent.tsx index 7739218b52..9452ee0d69 100644 --- a/src/client/app/components/daily-pattern/CreateDailyPatternModalComponent.tsx +++ b/src/client/app/components/day/CreateDailyPatternModalComponent.tsx @@ -44,7 +44,7 @@ export default function CreateDailyPatternModalComponent() { // Handlers for each type of input change const [patternState, setPatternState] = useState({ dailyPattern: { - name: defaultValues.name, + dayName: defaultValues.dayName, note: defaultValues.dailyPatternNote }, initialPattern: { @@ -59,7 +59,7 @@ export default function CreateDailyPatternModalComponent() { // Check if the daily pattern is valid const [isValidDailyPattern, reason] = useAppSelector(state => selectIsValidCreateDailyPattern(state, { - name: patternState.dailyPattern.name + dayName: patternState.dailyPattern.dayName // You can add more fields if your selector uses them }) ); @@ -87,7 +87,7 @@ export default function CreateDailyPatternModalComponent() { ...prev, dailyPattern: { ...prev.dailyPattern, - name: value + dayName: value } })); } @@ -121,10 +121,11 @@ export default function CreateDailyPatternModalComponent() { setShowWarningModal(false); // Add the new pattern and update the store addDailyPatternMutation({ - dayName: patternState.dailyPattern.name, + dayName: patternState.dailyPattern.dayName, slope: patternState.initialPattern.slope, intercept: patternState.initialPattern.intercept, - note: patternState.dailyPattern.note + note: patternState.dailyPattern.note, + segmentNote: patternState.initialPattern.note }); // Reset the state to default values resetState(); @@ -139,7 +140,7 @@ export default function CreateDailyPatternModalComponent() { const resetState = () => { setPatternState({ dailyPattern: { - name: defaultValues.name, + dayName: defaultValues.dayName, note: defaultValues.dailyPatternNote }, initialPattern: { @@ -164,10 +165,11 @@ export default function CreateDailyPatternModalComponent() { setShowModal(false); // Add the new pattern and update the store addDailyPatternMutation({ - dayName: patternState.dailyPattern.name, + dayName: patternState.dailyPattern.dayName, slope: patternState.initialPattern.slope, intercept: patternState.initialPattern.intercept, - note: patternState.dailyPattern.note + note: patternState.dailyPattern.note, + segmentNote: patternState.initialPattern.note }); // Reset the state to default values resetState(); @@ -213,8 +215,8 @@ export default function CreateDailyPatternModalComponent() { name='dailyPatternName' type='text' onChange={e => handleStringChange(e)} - value={patternState.dailyPattern.name} - invalid={!patternState.dailyPattern.name || patternState.dailyPattern.name.trim() === ''} + value={patternState.dailyPattern.dayName} + invalid={!patternState.dailyPattern.dayName || patternState.dailyPattern.dayName.trim() === ''} required /> diff --git a/src/client/app/components/daily-pattern/DayDetailComponent.tsx b/src/client/app/components/day/DayDetailComponent.tsx similarity index 68% rename from src/client/app/components/daily-pattern/DayDetailComponent.tsx rename to src/client/app/components/day/DayDetailComponent.tsx index 63ba1ec66a..d17b58e9c6 100644 --- a/src/client/app/components/daily-pattern/DayDetailComponent.tsx +++ b/src/client/app/components/day/DayDetailComponent.tsx @@ -9,30 +9,7 @@ import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; import { Day } from '../../types/redux/days'; import DayViewComponent from './DayViewComponent'; import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent'; - -/** - * Hardcoded day data for demonstration. - */ -const hardcodedDays: Day[] = [ - { - id: 1, - name: 'Weekday', - note: 'Standard weekday pattern', - segments: [ - { id: 1, dayId: 1, hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, - { id: 2, dayId: 1, hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, - { id: 3, dayId: 1, hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } - ] - }, - { - id: 2, - name: 'Weekend', - note: 'Weekend pattern', - segments: [ - { id: 1, dayId: 2, hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } - ] - } -]; +import { daysApi, stableEmptyDays } from '../../redux/api/daysApi'; /** * Defines the daily pattern info page @@ -42,17 +19,17 @@ const hardcodedDays: Day[] = [ export default function DayDetailComponent() { const locale = useAppSelector(selectSelectedLanguage); + // Day state + const { data: dayState = stableEmptyDays, isFetching: dayFetching } = daysApi.useGetDailyPatternsQuery(); + const tooltipStyle = { ...tooltipBaseStyle, tooltipDayView: 'help.admin.dailypatternview' }; - // Simulate loading state if needed - const [loading] = React.useState(false); - return (
- {loading ? ( + {dayFetching ? (
@@ -73,8 +50,8 @@ export default function DayDetailComponent() {
- {hardcodedDays - .sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase(), locale, { sensitivity: 'accent' })) + {Object.values(dayState) + .sort((a: Day, b: Day) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) .map(day => ( ))} diff --git a/src/client/app/components/daily-pattern/DayViewComponent.tsx b/src/client/app/components/day/DayViewComponent.tsx similarity index 97% rename from src/client/app/components/daily-pattern/DayViewComponent.tsx rename to src/client/app/components/day/DayViewComponent.tsx index 9cd09bb99c..36f1c0d8f1 100644 --- a/src/client/app/components/daily-pattern/DayViewComponent.tsx +++ b/src/client/app/components/day/DayViewComponent.tsx @@ -38,7 +38,7 @@ export default function DayViewComponent(props: DayViewComponentProps) { // Create header from sourceId, destinationId identifiers //const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + // unitDataById[props.conversion.destinationId]?.identifier); - const dayIdentifier = props.day.name || 'Unnamed Day'; + const dayIdentifier = props.day.dayName || 'Unnamed Day'; // Unlike the details component, we don't check if units are loaded since must come through that page. diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 4e5c2ccfba..5c77f37a0a 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -13,7 +13,7 @@ export const daysApi = baseApi.injectEndpoints({ query: id => `api/days/${id}`, providesTags: (result, error, id) => [{ type: 'DailyPattern', id }] }), - addDailyPattern: builder.mutation({ + addDailyPattern: builder.mutation({ query: dailyPattern => ({ url: 'api/days/add', method: 'POST', diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 56ae10b002..82e945d3c4 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -345,7 +345,7 @@ export const selectDefaultCreateDailyPatternValues = createAppSelector( [selectAllUnits], () => { const defaultValues = { - name: '', + dayName: '', dailyPatternNote: '', slope: 0, intercept: 0, @@ -410,10 +410,10 @@ export const isValidCreateMeter = createAppSelector( export const selectIsValidCreateDailyPattern = createAppSelector( [ - (_state, dailyPattern: { name: string }) => dailyPattern + (_state, dailyPattern: { dayName: string }) => dailyPattern ], (dailyPattern): [boolean, string] => { - if (!dailyPattern.name || dailyPattern.name.trim() === '') { + if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { return [false, translate('daily.patterns.create.name.required')]; } return [true, 'Daily Pattern is Valid']; diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index 444fff14f9..76b8089ad6 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,6 +1,6 @@ export interface Day { id: number; - name: string; + dayName: string; note: string; segments: DaySegment[]; } diff --git a/src/server/models/database.js b/src/server/models/database.js index c2d4dbcce5..40f5e58b06 100644 --- a/src/server/models/database.js +++ b/src/server/models/database.js @@ -97,11 +97,11 @@ async function createSchema(conn) { await Unit.createDisableChecksTypesEnum(conn); await Unit.createUnitRepresentTypesEnum(conn); await Unit.createTable(conn); - await Conversion.createTable(conn); - await ConversionSegment.createTable(conn); await Day.createTable(conn); await DaySegment.createTable(conn); await Week.createTable(conn); + await Conversion.createTable(conn); + await ConversionSegment.createTable(conn); await Cik.createTable(conn); await Meter.createMeterTypesEnum(conn); // This sql code creates a function to check meter's timezone. diff --git a/src/server/util/insertData.js b/src/server/util/insertData.js index fd416006a5..21b11a65ef 100644 --- a/src/server/util/insertData.js +++ b/src/server/util/insertData.js @@ -203,7 +203,20 @@ async function insertConversions(conversionsToInsert, conn) { const sourceName = (await Unit.getByName(conversionData.sourceName, conn)).id; const destinationName = (await Unit.getByName(conversionData.destinationName, conn)).id; if (await Conversion.getBySourceDestination(sourceName, destinationName, conn) === null) { - await new Conversion(sourceName, destinationName, conversionData.bidirectional, conversionData.slope, conversionData.intercept, conversionData.note).insert(conn); + await new Conversion( + sourceName, + destinationName, + conversionData.bidirectional, + conversionData.slope, + conversionData.intercept, + conversionData.note + ).insert( + conversionData.weekPatternsId, + conversionData.slope, + conversionData.intercept, + conversionData.note, + conn + ); } } } From 24abf08be35b88e639c38a9e77591cf2106f450a Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Wed, 23 Jul 2025 17:46:25 -0600 Subject: [PATCH 015/170] added check to make sure days cannot have the same name --- .../app/components/HeaderButtonsComponent.tsx | 14 +++---- src/client/app/redux/api/daysApi.ts | 2 +- .../app/redux/selectors/adminSelectors.ts | 38 ++++++++++++++----- src/client/app/translations/data.ts | 3 ++ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index cfb5886ce6..f8410eb08f 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -193,13 +193,6 @@ export default function HeaderButtonsComponent() { to="/conversions"> - - - + + + days ); diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 82e945d3c4..9ee683ac84 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -1,3 +1,4 @@ +/* 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/. */ @@ -15,6 +16,7 @@ import { AreaUnitType } from '../../utils/getAreaUnitConversion'; import { noUnitTranslated, potentialGraphicUnits } from '../../utils/input'; import translate from '../../utils/translate'; import { selectAllUnits, selectUnitDataById } from '../api/unitsApi'; +import { selectAllDays } from '../api/daysApi'; import { selectVisibleMetersAndGroups } from './authVisibilitySelectors'; import { createAppSelector } from './selectors'; import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; @@ -408,14 +410,32 @@ export const isValidCreateMeter = createAppSelector( } ); +/** + * Validates the creation of a new daily pattern. + * - Ensures the day name is not blank. + * - Ensures the day name does not already exist (case-insensitive) in the list of days. + * Returns a tuple: [isValid, message]. + * @param _state The Redux state (unused in this selector). + * @param dailyPattern The daily pattern object containing the day name. + * @param dailyPattern.dayName The name of the day to validate for uniqueness and non-blank value. + * @returns A tuple where the first element is a boolean indicating validity, and the second is a message string. + */ export const selectIsValidCreateDailyPattern = createAppSelector( - [ - (_state, dailyPattern: { dayName: string }) => dailyPattern - ], - (dailyPattern): [boolean, string] => { - if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { - return [false, translate('daily.patterns.create.name.required')]; - } - return [true, 'Daily Pattern is Valid']; - } + [ + selectAllDays, + (_state, dailyPattern: { dayName: string }) => dailyPattern + ], + (days, dailyPattern): [boolean, string] => { + if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { + return [false, translate('daily.patterns.create.name.required')]; + } + // Check if dayName already exists (case-insensitive) + const exists = days.some(day => + day.dayName?.toLowerCase() === dailyPattern.dayName.trim().toLowerCase() + ); + if (exists) { + return [false, translate('daily.patterns.create.name.exists')]; + } + return [true, 'Daily Pattern is Valid']; + } ); diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index baa1976a8b..aa83006c82 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -139,6 +139,7 @@ const LocaleTranslationData = { "daily.patterns": "Patterns - Daily", "daily.patterns.create": "Create a Daily Pattern", "daily.patterns.create.name.required": "Daily pattern name is required", + "daily.patterns.create.name.exists": "Daily pattern already exists", "daily.patterns.edit": "Details/Edit Daily Pattern", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?", "daily.patterns.start.hour": "Start Hour", @@ -721,6 +722,7 @@ const LocaleTranslationData = { "daily.patterns": "Patterns - Daily\u{26A1}", "daily.patterns.create": "Create a Daily Pattern\u{26A1}", "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", + "daily.patterns.create.name.exists": "Daily pattern already exists\u{26A1}", "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", @@ -1303,6 +1305,7 @@ const LocaleTranslationData = { "daily.patterns": "Patterns - Daily\u{26A1}", "daily.patterns.create": "Create a Daily Pattern\u{26A1}", "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", + "daily.patterns.create.name.exists": "Daily pattern already exists\u{26A1}", "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", From da3e43877c43849696a76a8d360cbf2016d6dd2f Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Thu, 24 Jul 2025 10:22:44 -0600 Subject: [PATCH 016/170] hooked conversion create modal - need to connect pattern --- .../conversion/CreateConversionModalComponent.tsx | 5 ++--- src/client/app/redux/api/conversionsApi.ts | 13 +++++++++++-- src/server/util/insertData.js | 4 +--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index 44577cb2f7..cff2c6069e 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -224,7 +224,7 @@ export default function CreateConversionModalComponent() { slope: conversionState.initialConversion.slope, intercept: conversionState.initialConversion.intercept, pattern: conversionState.initialConversion.pattern, - initialNote: conversionState.initialConversion.note + segmentNote: conversionState.initialConversion.note }; addConversionMutation(payload); resetState(); @@ -292,8 +292,7 @@ export default function CreateConversionModalComponent() { note: conversionState.overallConversion.note, slope: conversionState.initialConversion.slope, intercept: conversionState.initialConversion.intercept, - pattern: conversionState.initialConversion.pattern, - initialNote: conversionState.initialConversion.note + segmentNote: conversionState.initialConversion.note }; addConversionMutation(payload); resetState(); diff --git a/src/client/app/redux/api/conversionsApi.ts b/src/client/app/redux/api/conversionsApi.ts index 3bc5b1ba6a..c308101abb 100644 --- a/src/client/app/redux/api/conversionsApi.ts +++ b/src/client/app/redux/api/conversionsApi.ts @@ -18,9 +18,18 @@ export const conversionsApi = baseApi.injectEndpoints({ query: () => 'api/ciks', providesTags: ['Cik'] }), - addConversion: builder.mutation({ + addConversion: builder.mutation({ query: conversion => ({ - url: 'api/conversions/addConversion', + url: 'api/conversions/add', method: 'POST', body: conversion }), diff --git a/src/server/util/insertData.js b/src/server/util/insertData.js index 21b11a65ef..75f049228a 100644 --- a/src/server/util/insertData.js +++ b/src/server/util/insertData.js @@ -207,14 +207,12 @@ async function insertConversions(conversionsToInsert, conn) { sourceName, destinationName, conversionData.bidirectional, - conversionData.slope, - conversionData.intercept, conversionData.note ).insert( conversionData.weekPatternsId, conversionData.slope, conversionData.intercept, - conversionData.note, + conversionData.note, //this should be segment note conn ); } From 69c2ebbebd2e9c324d12080199942e0491bffbb9 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Thu, 24 Jul 2025 11:19:33 -0600 Subject: [PATCH 017/170] fix merge errors --- .gitignore | 3 ++ docker-compose.yml | 6 ++-- .../components/days/DayDetailComponent.tsx | 33 ------------------- .../weeks/CreateWeekModalComponent.tsx | 2 +- .../weeks/EditWeekModalComponent.tsx | 2 +- 5 files changed, 9 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 0910e89c44..dd7914a64a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ src/server/tmp/* #ui-testing screenshots src/cypress/screenshots/* + +#docker +docker-compose.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d03dad2338..2585877e92 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. volumes: - - ./postgres-data:/var/lib/postgresql/data/pgdata + - postgres-data:/var/lib/postgresql/data/pgdata healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s @@ -111,4 +111,6 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" - \ No newline at end of file + +volumes: + postgres-data: \ No newline at end of file diff --git a/src/client/app/components/days/DayDetailComponent.tsx b/src/client/app/components/days/DayDetailComponent.tsx index ff5ae71c5f..7a0014b7c3 100644 --- a/src/client/app/components/days/DayDetailComponent.tsx +++ b/src/client/app/components/days/DayDetailComponent.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Button } from 'reactstrap'; import { useAppSelector } from '../../redux/reduxHooks'; import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; @@ -9,35 +8,8 @@ import SpinnerComponent from '../SpinnerComponent'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import DayViewComponent from './DayViewComponent'; -<<<<<<< HEAD:src/client/app/components/day/DayDetailComponent.tsx import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent'; import { daysApi, stableEmptyDays } from '../../redux/api/daysApi'; -======= - -/** - * Hardcoded day data for demonstration. - */ -const hardcodedDays: Day[] = [ - { - id: 1, - dayName: 'Weekday', - note: 'Standard weekday pattern', - segments: [ - { id: '1-1', dayId: '1', hour: 0, slope: 0.5, intercept: 1, note: 'Midnight to morning' }, - { id: '1-2', dayId: '1', hour: 8, slope: 0.7, intercept: 1.2, note: 'Morning to evening' }, - { id: '1-3', dayId: '1', hour: 18, slope: 0.4, intercept: 0.8, note: 'Evening to midnight' } - ] - }, - { - id: 2, - dayName: 'Weekend', - note: 'Weekend pattern', - segments: [ - { id: '2-1', dayId: '2', hour: 0, slope: 0.3, intercept: 0.9, note: 'All day' } - ] - } -]; ->>>>>>> origin/evan-carey/weeks-api:src/client/app/components/daily-pattern/DayDetailComponent.tsx /** * Defines the daily pattern info page @@ -78,13 +50,8 @@ export default function DayDetailComponent() {
-<<<<<<< HEAD:src/client/app/components/day/DayDetailComponent.tsx {Object.values(dayState) .sort((a: Day, b: Day) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) -======= - {hardcodedDays - .sort((a, b) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) ->>>>>>> origin/evan-carey/weeks-api:src/client/app/components/daily-pattern/DayDetailComponent.tsx .map(day => ( ))} diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index b83bd84577..71566ac196 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -26,7 +26,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { const [showModal, setShowModal] = React.useState(false); // Fetch days data - const { data: days, isFetching: isFetchingDays } = daysApi.useGetDaysDetailsQuery(); + const { data: days, isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); // Sort days by day name to make the dropdown more user-friendly const sortedDays = React.useMemo(() => { diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx index d36dc84a37..b5df59bae9 100644 --- a/src/client/app/components/weeks/EditWeekModalComponent.tsx +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -45,7 +45,7 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp const [weekDetails, setWeekDetails] = React.useState({ ...props.week }); // Fetch days data - const { data: days, isFetching: isFetchingDays } = daysApi.useGetDaysDetailsQuery(); + const { data: days, isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); // Sort days by day name to make the dropdown more user-friendly const sortedDays = React.useMemo(() => { From 7bb4599de36266daa78d98f800e0a2c66cd9e216 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Thu, 24 Jul 2025 12:18:21 -0600 Subject: [PATCH 018/170] hooked up create conversion to backend --- .../CreateConversionModalComponent.tsx | 94 +++++++------------ .../days/CreateDailyPatternModalComponent.tsx | 2 + src/client/app/translations/data.ts | 24 ++--- 3 files changed, 46 insertions(+), 74 deletions(-) diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index cff2c6069e..40433d8e6c 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -19,6 +19,7 @@ import { useTranslate } from '../../redux/componentHooks'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import { UnitType } from '../../types/redux/units'; +import { weeksApi } from '../../redux/api/weeksApi'; /** * Defines the create conversion modal form @@ -31,6 +32,9 @@ export default function CreateConversionModalComponent() { const defaultValues = useAppSelector(selectDefaultCreateConversionValues); + // Fetch all weeks from API + const { data: weeks = [], isFetching: isFetchingWeeks } = weeksApi.useGetWeeksQuery(); + /* State */ // Modal show const [showModal, setShowModal] = useState(false); @@ -101,28 +105,6 @@ export default function CreateConversionModalComponent() { })); } }; - // const handleBooleanChange = (e: React.ChangeEvent) => { - // setConversionState({ ...conversionState, [e.target.name]: JSON.parse(e.target.value) }); - // }; - - // 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 handleNumberChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -183,14 +165,14 @@ export default function CreateConversionModalComponent() { }; const handleInitialPatternChange = (e: React.ChangeEvent) => { - const selectedPattern = e.target.value; + const selectedValue = e.target.value; setConversionState(prev => ({ ...prev, initialConversion: { ...prev.initialConversion, - pattern: selectedPattern, - slope: selectedPattern === 'No Pattern' ? prev.initialConversion.slope : 0, - intercept: selectedPattern === 'No Pattern' ? prev.initialConversion.intercept : 0 + pattern: selectedValue, // week ID as string or "No Pattern" + slope: selectedValue === 'No Pattern' ? prev.initialConversion.slope : 0, + intercept: selectedValue === 'No Pattern' ? prev.initialConversion.intercept : 0 } })); }; @@ -213,9 +195,10 @@ export default function CreateConversionModalComponent() { const handleWarningConfirm = () => { //Close the warning modal setShowWarningModal(false); - - // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready - // Create a placeholder payload for the conversion creation until backend is ready + const weekPatternsId = + conversionState.initialConversion.pattern === 'No Pattern' + ? undefined + : Number(conversionState.initialConversion.pattern); const payload = { sourceId: conversionState.overallConversion.sourceId, destinationId: conversionState.overallConversion.destinationId, @@ -223,7 +206,7 @@ export default function CreateConversionModalComponent() { note: conversionState.overallConversion.note, slope: conversionState.initialConversion.slope, intercept: conversionState.initialConversion.intercept, - pattern: conversionState.initialConversion.pattern, + weekPatternsId, segmentNote: conversionState.initialConversion.note }; addConversionMutation(payload); @@ -269,30 +252,19 @@ export default function CreateConversionModalComponent() { } 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 - - // TODO: Replace this with a proper overall and initial conversion creation logic once the backend is ready - // Example updated logic for creating a conversion: - // 1. Create overall conversion - //const overallResult = await addConversionMutation(conversionState.overallConversion).unwrap(); - // 2. Create initial conversion with foreign key - // await addConversionMutation({ - // ...conversionState.initialConversion, - // overallConversionId: overallResult.id - // }); - - // Create a placeholder payload for the conversion creation until backend is ready + const weekPatternsId = + conversionState.initialConversion.pattern === 'No Pattern' + ? undefined + : 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, - segmentNote: conversionState.initialConversion.note + 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.note }; addConversionMutation(payload); resetState(); @@ -343,6 +315,7 @@ export default function CreateConversionModalComponent() { type='select' value={conversionState.overallConversion.sourceId} onChange={e => handleNumberChange(e)} + disabled={isFetchingWeeks} invalid={conversionState.overallConversion.sourceId === -999}> { - {defaultValues.weeklyPatterns?.map(p => ( - - ))} */} - - - + onChange={handleInitialPatternChange} + > + + {weeks.map(week => ( + + ))} {/* Note input for initial conversion*/} diff --git a/src/client/app/components/days/CreateDailyPatternModalComponent.tsx b/src/client/app/components/days/CreateDailyPatternModalComponent.tsx index 9452ee0d69..b148395a95 100644 --- a/src/client/app/components/days/CreateDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/CreateDailyPatternModalComponent.tsx @@ -129,6 +129,8 @@ export default function CreateDailyPatternModalComponent() { }); // Reset the state to default values resetState(); + // Close the modal + setShowModal(false); }; const handleWarningCancel = () => { diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index baaaecc99e..447e67a1ce 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -137,10 +137,10 @@ const LocaleTranslationData = { "custom.date.range": "Custom date range", "custom.value": "Custom value", "daily.patterns": "Patterns - Daily", - "daily.patterns.create": "Create a Daily Pattern", - "daily.patterns.create.name.required": "Daily pattern name is required", - "daily.patterns.create.name.exists": "Daily pattern already exists", - "daily.patterns.edit": "Details/Edit Daily Pattern", + "daily.patterns.create": "Create a Day", + "daily.patterns.create.name.required": "Day name is required", + "daily.patterns.create.name.exists": "Day already exists", + "daily.patterns.edit": "Details/Edit Day", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?", "daily.patterns.start.hour": "Start Hour", "daily.patterns.end.hour": "End Hour", @@ -746,10 +746,10 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", "daily.patterns": "Patterns - Daily\u{26A1}", - "daily.patterns.create": "Create a Daily Pattern\u{26A1}", - "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", - "daily.patterns.create.name.exists": "Daily pattern already exists\u{26A1}", - "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.create": "Create a Day\u{26A1}", + "daily.patterns.create.name.required": "Day name is required\u{26A1}", + "daily.patterns.create.name.exists": "Day already exists\u{26A1}", + "daily.patterns.edit": "Details/Edit Day\u{26A1}", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", @@ -1355,10 +1355,10 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", "daily.patterns": "Patterns - Daily\u{26A1}", - "daily.patterns.create": "Create a Daily Pattern\u{26A1}", - "daily.patterns.create.name.required": "Daily pattern name is required\u{26A1}", - "daily.patterns.create.name.exists": "Daily pattern already exists\u{26A1}", - "daily.patterns.edit": "Details/Edit Daily Pattern\u{26A1}", + "daily.patterns.create": "Create a Day\u{26A1}", + "daily.patterns.create.name.required": "Day name is required\u{26A1}", + "daily.patterns.create.name.exists": "Day already exists\u{26A1}", + "daily.patterns.edit": "Details/Edit Day\u{26A1}", "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", From e9bdc07589b006e7a39799430050a6c9c6923f32 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Thu, 24 Jul 2025 12:30:05 -0600 Subject: [PATCH 019/170] revert changes to git.ignore and docker-compose.yml --- .gitignore | 5 +---- docker-compose.yml | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index dd7914a64a..dfe96eab93 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,4 @@ nohup.out src/server/tmp/* #ui-testing screenshots -src/cypress/screenshots/* - -#docker -docker-compose.yml \ No newline at end of file +src/cypress/screenshots/* \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2585877e92..f628ebb2a0 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=pleaseChange # default postgres password that should be changed for security. volumes: - - postgres-data:/var/lib/postgresql/data/pgdata + - ./postgres-data:/var/lib/postgresql/data/pgdata healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s @@ -110,7 +110,4 @@ services: entrypoint: > /bin/sh -c " rm -f /tmp/.X99-lock && - Xvfb :99 -screen 0 1024x768x16" - -volumes: - postgres-data: \ No newline at end of file + Xvfb :99 -screen 0 1024x768x16" \ No newline at end of file From 92bd3526a3ac34c52477912a96ac0a3ce0854079 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Sat, 26 Jul 2025 12:53:23 -0700 Subject: [PATCH 020/170] undo formatting changes to docker-compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index d03dad2338..24f0d24385 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,4 +111,4 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" - \ No newline at end of file + From 109fd51986d5ead7520eb2518a3d0f949f81259f Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Sat, 26 Jul 2025 13:00:42 -0700 Subject: [PATCH 021/170] undo formatting change to docker-compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 24f0d24385..b5721dc5fe 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,4 +111,4 @@ services: /bin/sh -c " rm -f /tmp/.X99-lock && Xvfb :99 -screen 0 1024x768x16" - + From 026b882b33473c37f3ada5f6aaf8303fae18951b Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Sat, 26 Jul 2025 14:35:20 -0700 Subject: [PATCH 022/170] Address code review comments for Weeks feature weeksApi * remove unused selectors * remove extra newline at eof CreateWeekModalComponent * Replace toggleModal with separate show and close functions to match rest of codebase * use generic `handleStringChange` and `handleNumberChange` function to update model from form. * remove .trim() from week name validation. * don't autofocus on first input field. This may cause issues for screen readers EditWeekModalComponent * use generic `handleStringChange` and `handleNumberChange` function to update model from form. * remove .trim() from week name validation. * remove unnecessary validation checks. * remove unnecessary comments. WeeksDetailComponent * fix alignment of loading spinner WeekViewComponent * limit length of note visible in card to 30 characters --- .../weeks/CreateWeekModalComponent.tsx | 42 +++++++++--------- .../weeks/EditWeekModalComponent.tsx | 44 ++++++++----------- .../components/weeks/WeekViewComponent.tsx | 3 +- .../components/weeks/WeeksDetailComponent.tsx | 4 +- src/client/app/redux/api/weeksApi.ts | 8 ---- 5 files changed, 44 insertions(+), 57 deletions(-) diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index b83bd84577..ea23f72193 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -47,8 +47,16 @@ export default function CreateWeekModalComponent(): React.ReactElement { // State to hold the week details being created. Initialized with default values. const [weekDetails, setWeekDetails] = React.useState>(defaultValues); - // Function to toggle the modal visibility - const toggleModal = () => setShowModal(!showModal); + const handleShowModal = () => setShowModal(true); + const handleCloseModal = () => setShowModal(false); + + 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) }); + }; // Function to handle form submission // Validates the week details and submits them to the API @@ -60,14 +68,14 @@ export default function CreateWeekModalComponent(): React.ReactElement { addWeekMutation(weekDetails).unwrap() .then(() => { showSuccessNotification(translate('week.create.success')); - toggleModal(); + handleCloseModal(); }) .catch(error => { showErrorNotification(translate('week.create.failure') + error); }); }; - // Function to reset the week details to default values + // Function to reset the week details to default values. Called when modal is closed. const resetState = () => { setWeekDetails(defaultValues); }; @@ -78,13 +86,12 @@ export default function CreateWeekModalComponent(): React.ReactElement { // Validate the week name to ensure it is not empty and does not already exist const isWeekNameValid = React.useMemo(() => { - const trimmedName = weekDetails.weekName.trim(); - if (trimmedName === '') { + if (weekDetails.weekName === '') { setNameValidationMessageId('error.required'); return false; } - if (weeks?.some(week => week.weekName.toLowerCase() === trimmedName.toLowerCase())) { + if (weeks?.some(week => week.weekName.toLowerCase() === weekDetails.weekName.toLowerCase())) { setNameValidationMessageId('week.validation.name.exists'); return false; } @@ -106,25 +113,21 @@ export default function CreateWeekModalComponent(): React.ReactElement { weekDetails.saturday !== -999; }, [weekDetails, isWeekNameValid]); - // Reference to the name input field to set focus when the modal opens - const nameInputFieldRef = React.useRef(null); - return ( <> {/* Show create modal button */} - nameInputFieldRef.current?.focus()} onClosed={resetState} > - +
@@ -141,12 +144,11 @@ export default function CreateWeekModalComponent(): React.ReactElement { setWeekDetails({ ...weekDetails, weekName: event.target.value })} - innerRef={nameInputFieldRef} // Set focus on this field when modal opens + onChange={handleStringChange} /> {nameValidationMessageId && } @@ -164,7 +166,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { type="textarea" name="note" value={weekDetails.note} - onChange={event => setWeekDetails({ ...weekDetails, note: event.target.value })} /> + onChange={handleStringChange} /> @@ -189,7 +191,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { value={weekDetails[day]} invalid={weekDetails[day] === -999} disabled={isFetchingDays} - onChange={event => setWeekDetails({ ...weekDetails, [day]: Number(event.target.value) })} + onChange={handleNumberChange} >
); -} \ No newline at end of file +} diff --git a/src/client/app/components/days/EditDailyPatternModalComponent.tsx b/src/client/app/components/days/EditDailyPatternModalComponent.tsx index ae7e7d02bd..a5cf2c7f3b 100644 --- a/src/client/app/components/days/EditDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/EditDailyPatternModalComponent.tsx @@ -1,329 +1,372 @@ // EditDailyPatternModalComponent.tsx import * as React from 'react'; -import * as moment from 'moment-timezone'; +import { FormattedMessage } from 'react-intl'; import { - Button, Container, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, - ModalHeader, Row, Col, Table, Pagination, PaginationItem, PaginationLink, - FormFeedback + Button, + Col, + Container, + FormFeedback, + FormGroup, Input, Label, Modal, ModalBody, ModalFooter, + ModalHeader, + Pagination, PaginationItem, PaginationLink, + Row, + Table } from 'reactstrap'; import { Day, DaySegment } from 'types/redux/days'; +import { daysApi } from '../../redux/api/daysApi'; +import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { LocaleDataKey } from '../../translations/data'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import translate from '../../utils/translate'; +import EditDaySegmentModalComponent from './EditDaySegmentModalComponent'; // export interface Props { - show: boolean; - day: Day; - handleClose: () => void; + show: boolean; + day: Day; + handleClose: () => void; } // Segment interface extends DaySegment to include id and date range -interface Segment extends DaySegment { - id: number; - start: string; // ISO | '-inf' - end: string; // ISO | '+inf' - pattern: string; -} +// interface Segment extends DaySegment { +// id: number; +// start: string; // ISO | '-inf' +// end: string; // ISO | '+inf' +// pattern: string; +// } const PER_PAGE = 10; /* spec‑accurate range text */ -const tsFmt = (iso: string) => - moment.parseZone(iso).format('MMMM D, YYYY hh:mm:ss A'); +// const tsFmt = (iso: string) => +// moment.parseZone(iso).format('MMMM D, YYYY hh:mm:ss A'); + +// const dateRange = (s: DaySegment) => +// s.start === '-inf' +// ? `to ${tsFmt(s.end)}` +// : s.end === '+inf' +// ? `from ${tsFmt(s.start)}` +// : `${tsFmt(s.start)} to ${tsFmt(s.end)}`; -const dateRange = (s: Segment) => - s.start === '-inf' - ? `to ${tsFmt(s.end)}` - : s.end === '+inf' - ? `from ${tsFmt(s.start)}` - : `${tsFmt(s.start)} to ${tsFmt(s.end)}`; +/** + * Given an hour in range [0, 24], returns the time in format HH:MM{AM|PM} + * @param hour The hour to transform + * @returns time in format HH:MM{AM|PM} + */ +function hourToTime(hour: number) { + const suffix = hour === 0 || hour === 24 ? 'AM' : hour < 12 ? 'AM' : 'PM'; + let displayHour = hour % 12; + if (displayHour === 0) { + displayHour = 12; + } + return `${displayHour}:00 ${suffix}`; +} // Main component for editing daily patterns // Displays a modal with a table of segments and options to edit, split, or delete them export default function EditDailyPatternModalComponent({ show, day, handleClose }: Props) { - const [segments, setSegments] = React.useState( - (day.segments as unknown as Segment[]) ?? [] - ); - - /* --- TEMP DEMO ROWS -------------------------------------------------- */ - React.useEffect(() => { - if (segments.length === 0) { - setSegments([ - /* 1 ─ full dates, No Pattern */ - { - id: 1, - start: '2024-03-09T00:00:00Z', - end: '2025-01-02T00:00:00Z', - slope: 123.45, - intercept: 0, - pattern: 'No Pattern', - note: 'Sample with full dates and no pattern', - dayId: 0, - hour: 0 - }, - /* 2 ─ full dates, Weekly ABC */ - { - id: 2, - start: '2024-03-09T00:00:00Z', - end: '2025-01-02T00:00:00Z', - slope: 0, - intercept: 0, - pattern: 'Weekly ABC', - note: 'Sample with full dates and pattern', - dayId: 0, - hour: 0 - }, - /* 3 ─ from -∞ to a date */ - { - id: 3, - start: '-inf', - end: '2025-01-02T00:00:00Z', - slope: 123.45, - intercept: 0, - pattern: 'No Pattern', - note: 'Sample where it applies from -inf and no pattern', - dayId: 0, - hour: 0 - }, - /* 4 ─ from a date to +∞, Weekly XYZ */ - { - id: 4, - start: '2025-01-02T00:00:00Z', - end: '+inf', - slope: 0, - intercept: 0, - pattern: 'Weekly XYZ', - note: 'Sample where it applies to +inf and pattern', - dayId: 0, - hour: 0 - } - ]); - } - - }, []); - - - // Pagination - const [page, setPage] = React.useState(1); - const totalPages = Math.ceil(segments.length / PER_PAGE); - const paged = segments.slice((page - 1) * PER_PAGE, page * PER_PAGE); - - // Editing state - const [editing, setEditing] = React.useState(null); - const [split, setSplit] = React.useState<{ - seg: Segment | null; - dir: 'earlier' | 'later' | null; - dt: string; - err: string; - }>({ seg: null, dir: null, dt: '', err: '' }); - - // Update a segment field - const upd = (id: number, field: keyof Segment, val: any) => - setSegments(prev => prev.map(s => (s.id === id ? { ...s, [field]: val } : s))); - - const deletePart = (seg: Segment, dir: 'earlier' | 'later') => { - const mid = moment(seg.start).add( - moment(seg.end).diff(moment(seg.start)) / 2 - ).toISOString(); - if (dir === 'earlier') upd(seg.id, 'start', mid); - else upd(seg.id, 'end', mid); - }; - - const splitNow = () => { - if (!split.seg) return; - const dt = moment(split.dt, 'YYYY-MM-DD HH:mm:ss', true); - if ( - !dt.isValid() || - !dt.isBetween(moment(split.seg.start), moment(split.seg.end)) - ) { - setSplit(m => ({ ...m, err: 'Must be inside current range' })); - return; - } - const first = { ...split.seg, end: dt.toISOString() }; - const second = { ...split.seg, start: dt.toISOString() }; - setSegments(prev => - prev - .filter(s => s.id !== split.seg!.id) - .concat(split.dir === 'earlier' ? [first, second] : [second, first]) - .sort((a, b) => moment(a.start).valueOf() - moment(b.start).valueOf()) - ); - setSplit({ seg: null, dir: null, dt: '', err: '' }); - }; - - /* ── Render ───────────────────────────────────────────── */ - return ( - <> - - Details/Edit Day - - - - {/* Name and Note */} - - - - - - - - - - - - - - - - {/* Table */} -
Conversion Table
- - - - - - - - - - {paged.map(seg => ( - - - - - - - - - - - - - ))} - -
Date RangeSlopeInterceptPatternNoteEditSplit ↑Split ↓Del ↑Del ↓
{dateRange(seg)}{seg.pattern === 'No Pattern' ? seg.slope : ''}{seg.pattern === 'No Pattern' ? seg.intercept : ''}{seg.pattern} - {(seg.note ?? '').length > 100 - ? (seg.note ?? '').slice(0, 100) + ' …' - : seg.note ?? ''} -
- - {/* Pagination */} - {segments.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)} /> - - - )} -
-
- - - - - -
- - {/* Edit‑segment modal */} - {editing && ( - setEditing(null)}> - Edit Segment - -

{dateRange(editing)}

- - - setEditing({ ...editing, slope: +e.target.value })} - disabled={editing.pattern !== 'No Pattern'} - /> - - - - setEditing({ ...editing, intercept: +e.target.value })} - disabled={editing.pattern !== 'No Pattern'} - /> - - - - setEditing({ ...editing, pattern: e.target.value })} - > - - - - - - - - setEditing({ ...editing, note: e.target.value })} - /> - -
- - - - -
- )} - - {/* Split modal */} - {split.seg && ( - setSplit({ seg: null, dir: null, dt: '', err: '' })}> - - Split {split.dir === 'earlier' ? 'Earlier ↑' : 'Later ↓'} - - -

- Enter datetime (YYYY‑MM‑DD HH:mm:ss) between
- {dateRange(split.seg)} -

- setSplit(m => ({ ...m, dt: e.target.value, err: '' }))} - invalid={Boolean(split.err)} - /> - {split.err && {split.err}} -
- - - - -
- )} - - ); -} \ No newline at end of file + // const [segments, setSegments] = React.useState( + // (day.segments as unknown as Segment[]) ?? [] + // ); + + const [dayDetails, setDayDetails] = React.useState({ ...day }); + + const { data: daySegments, isFetching: isFetchingDaySegments } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(day.id); + + const [editDayMutation, { isLoading: isSaving }] = daysApi.useEditDailyPatternMutation(); + + const [addDaySegmentMutation, { isLoading: isAddingDaySegment }] = daySegmentsApi.useAddDailyPatternSegmentMutation(); + + const handleStringChange = (e: React.ChangeEvent) => { + setDayDetails({ ...dayDetails, [e.target.name]: e.target.value }); + }; + + const handleSubmit = () => { + editDayMutation(dayDetails).unwrap() + .then(() => { + // TODO: internationalize + showSuccessNotification('Successfully edited day.'); + handleClose(); + }).catch(error => { + // TODO: internationalize + showErrorNotification(error); + }); + }; + + /* --- TEMP DEMO ROWS -------------------------------------------------- */ + // React.useEffect(() => { + // if (segments.length === 0) { + // setSegments([ + // /* 1 ─ full dates, No Pattern */ + // { + // id: 1, + // start: '2024-03-09T00:00:00Z', + // end: '2025-01-02T00:00:00Z', + // slope: 123.45, + // intercept: 0, + // pattern: 'No Pattern', + // note: 'Sample with full dates and no pattern', + // dayId: 0, + // hour: 0 + // }, + // /* 2 ─ full dates, Weekly ABC */ + // { + // id: 2, + // start: '2024-03-09T00:00:00Z', + // end: '2025-01-02T00:00:00Z', + // slope: 0, + // intercept: 0, + // pattern: 'Weekly ABC', + // note: 'Sample with full dates and pattern', + // dayId: 0, + // hour: 0 + // }, + // /* 3 ─ from -∞ to a date */ + // { + // id: 3, + // start: '-inf', + // end: '2025-01-02T00:00:00Z', + // slope: 123.45, + // intercept: 0, + // pattern: 'No Pattern', + // note: 'Sample where it applies from -inf and no pattern', + // dayId: 0, + // hour: 0 + // }, + // /* 4 ─ from a date to +∞, Weekly XYZ */ + // { + // id: 4, + // start: '2025-01-02T00:00:00Z', + // end: '+inf', + // slope: 0, + // intercept: 0, + // pattern: 'Weekly XYZ', + // note: 'Sample where it applies to +inf and pattern', + // dayId: 0, + // hour: 0 + // } + // ]); + // } + + // }, []); + + + // Pagination + const [page, setPage] = React.useState(1); + const totalPages = Math.ceil(daySegments?.length ?? 1 / PER_PAGE); + const paged = daySegments?.slice((page - 1) * PER_PAGE, page * PER_PAGE); + + // Editing state + // const [editing, setEditing] = React.useState(null); + const [split, setSplit] = React.useState<{ + seg: DaySegment | null; + dir: 'earlier' | 'later' | null; + dt: string; + err: string; + }>({ seg: null, dir: null, dt: '', err: '' }); + + // 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); + + const isDayValid = React.useMemo(() => { + if (dayDetails.name === '') { + setNameValidationMessageId('error.required'); + return false; + } + setNameValidationMessageId(null); + return true; + }, [dayDetails]); + + const [deleteDayMutation, { isLoading: isDeleting }] = daysApi.useDeleteDailyPatternMutation(); + + // Update a segment field + // const upd = (id: number, field: keyof DaySegment, val: any) => + // setSegments(prev => prev.map(s => (s.id === id ? { ...s, [field]: val } : s))); + + // const deletePart = (seg: DaySegment, dir: 'earlier' | 'later') => { + // const mid = moment(seg.start).add( + // moment(seg.end).diff(moment(seg.start)) / 2 + // ).toISOString(); + // if (dir === 'earlier') upd(seg.id, 'start', mid); + // else upd(seg.id, 'end', mid); + // }; + + // const splitNow = () => { + // if (!split.seg) return; + // const dt = moment(split.dt, 'YYYY-MM-DD HH:mm:ss', true); + // if ( + // !dt.isValid() || + // !dt.isBetween(moment(split.seg.start), moment(split.seg.end)) + // ) { + // setSplit(m => ({ ...m, err: 'Must be inside current range' })); + // return; + // } + // const first = { ...split.seg, end: dt.toISOString() }; + // const second = { ...split.seg, start: dt.toISOString() }; + // setSegments(prev => + // prev + // .filter(s => s.id !== split.seg!.id) + // .concat(split.dir === 'earlier' ? [first, second] : [second, first]) + // .sort((a, b) => moment(a.start).valueOf() - moment(b.start).valueOf()) + // ); + // setSplit({ seg: null, dir: null, dt: '', err: '' }); + // }; + + /* ── Render ───────────────────────────────────────────── */ + return ( + <> + + {/* TODO: internationalize */} + + + + + + + {/* Name and Note */} + + + + + + + {nameValidationMessageId && } + + + + + + + + + + + + {/* Table */} + {/* TODO: internationalize */} +
Segments Table
+ + + + + + + + + + + + + + + + {paged?.map(seg => ( + + + + + + + + + + + + ))} + +
Date RangeSlopeInterceptNoteEditSplit ↑Split ↓Del ↑Del ↓
{hourToTime(seg.startHour)} - {hourToTime(seg.endHour)}{seg.slope}{seg.intercept} + {(seg.note ?? '').length > 100 + ? (seg.note ?? '').slice(0, 100) + ' …' + : seg.note ?? ''} + + + + + + + + + + +
+ + {/* 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)} /> + + + )} +
+
+ + + + + +
+ + + + {/* Split modal */} + { + split.seg && ( + setSplit({ seg: null, dir: null, dt: '', err: '' })}> + + Split {split.dir === 'earlier' ? 'Earlier ↑' : 'Later ↓'} + + +

+ Enter datetime (YYYY‑MM‑DD HH:mm:ss) between
+ {/* {dateRange(split.seg)} */} +

+ setSplit(m => ({ ...m, dt: e.target.value, err: '' }))} + invalid={Boolean(split.err)} + /> + {split.err && {split.err}} +
+ + + + +
+ ) + } + + ); +} diff --git a/src/client/app/components/days/EditDaySegmentModalComponent.tsx b/src/client/app/components/days/EditDaySegmentModalComponent.tsx new file mode 100644 index 0000000000..26570422ea --- /dev/null +++ b/src/client/app/components/days/EditDaySegmentModalComponent.tsx @@ -0,0 +1,128 @@ +/* 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, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { DaySegment } from '../../types/redux/days'; + + +/** + * Given an hour in range [0, 24], returns the time in format HH:MM{AM|PM} + * @param hour The hour to transform + * @returns time in format HH:MM{AM|PM} + */ +function hourToTime(hour: number) { + const suffix = hour === 0 || hour === 24 ? 'AM' : hour < 12 ? 'AM' : 'PM'; + let displayHour = hour % 12; + if (displayHour === 0) { + displayHour = 12; + } + return `${displayHour}:00 ${suffix}`; +} + +interface EditDaySegmentModalComponentProps { + /** + * The week to edit + */ + daySegment: DaySegment; +} + +/** + * /** + * 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 }); + + const [showEditModal, setShowEditModal] = React.useState(false); + + const [editDaySegmentMutation, { isLoading: isSaving }] = daySegmentsApi.useEditDailyPatternSegmentMutation(); + + 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 = () => { + editDaySegmentMutation(daySegment).unwrap() + .then(() => { + handleHideEditModal(); + }); + }; + + const handleShowEditModal = () => setShowEditModal(true); + const handleHideEditModal = () => setShowEditModal(false); + + return ( + <> + + + + + {/* TODO: internationalize */} + Edit Segment + + + {hourToTime(daySegment.startHour)} - {hourToTime(daySegment.endHour)} + + + + + + + + + + + + + + + + {/* Discard changes */} + + {/* Save changes */} + + + + + ); +} diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index 6e52716a18..349dd672b7 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -26,14 +26,14 @@ export default function CreateWeekModalComponent(): React.ReactElement { const [showModal, setShowModal] = React.useState(false); // Fetch days data - const { data: days, isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); + const { data: days = [], isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); // 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.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); }, [days]); // Fetch weeks data (used to check if week name already exists) @@ -199,7 +199,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { {sortedDays?.map(day => ( ))} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx index 20a26f2881..52b6021e3e 100644 --- a/src/client/app/components/weeks/EditWeekModalComponent.tsx +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -51,7 +51,7 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp if (!days) { return []; } - return [...days].sort((a, b) => a.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); }, [days]); // Fetch weeks data (used to check if week name already exists) @@ -193,7 +193,7 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp > {sortedDays?.map(day => ( ))} diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts index 3f0e50040d..fd210cb13c 100644 --- a/src/client/app/redux/api/daySegmentsApi.ts +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -1,7 +1,8 @@ import { createSelector } from '@reduxjs/toolkit'; -import { baseApi } from './baseApi'; import { DaySegment } from '../../types/redux/days'; // Adjust path as needed +import { baseApi } from './baseApi'; +// TODO: fix tags and invalidation export const daySegmentsApi = baseApi.injectEndpoints({ endpoints: builder => ({ getDailyPatternSegments: builder.query({ @@ -13,7 +14,11 @@ export const daySegmentsApi = baseApi.injectEndpoints({ providesTags: (result, error, id) => [{ type: 'DailyPatternSegment', id }] }), getDailyPatternSegmentsByDayId: builder.query({ - query: dayId => `api/daySegments/dayId/${dayId}`, + query: dayId => ({ + url: 'api/daySegments/segments', + method: 'POST', + body: { dayId } + }), providesTags: (result, error, dayId) => [{ type: 'DailyPatternSegment', dayId }] }), addDailyPatternSegment: builder.mutation>({ @@ -30,7 +35,7 @@ export const daySegmentsApi = baseApi.injectEndpoints({ method: 'POST', body: daySegment }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] + invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', dayId: arg.dayId }] }), deleteDailyPatternSegment: builder.mutation({ query: ({ id }) => ({ @@ -58,4 +63,4 @@ export const { useAddDailyPatternSegmentMutation, useEditDailyPatternSegmentMutation, useDeleteDailyPatternSegmentMutation -} = daySegmentsApi; \ No newline at end of file +} = daySegmentsApi; diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 12c587c411..4ed9c031bd 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -27,6 +27,7 @@ export const daysApi = baseApi.injectEndpoints({ method: 'POST', body: dailyPattern }), + transformErrorResponse: res => res.data, invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] }), deleteDailyPattern: builder.mutation({ @@ -54,4 +55,4 @@ export const { useAddDailyPatternMutation, useEditDailyPatternMutation, useDeleteDailyPatternMutation -} = daysApi; \ No newline at end of file +} = daysApi; diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index c56f20c627..0718516932 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -17,8 +17,8 @@ import { unitsCompatibleWithUnit } from '../../utils/determineCompatibleUnits'; import { AreaUnitType } from '../../utils/getAreaUnitConversion'; import { MAX_VAL, MIN_VAL, noUnitTranslated, potentialGraphicUnits } from '../../utils/input'; import translate from '../../utils/translate'; -import { selectAllUnits, selectUnitDataById } from '../api/unitsApi'; import { selectAllDays } from '../api/daysApi'; +import { selectAllUnits, selectUnitDataById } from '../api/unitsApi'; import { selectVisibleMetersAndGroups } from './authVisibilitySelectors'; import { createAppSelector } from './selectors'; @@ -350,7 +350,7 @@ export const selectDefaultCreateDailyPatternValues = createAppSelector( dailyPatternNote: '', slope: 0, intercept: 0, - startHour:0, + startHour: 0, endHour: 24, initialPatternNote: '' }; @@ -435,21 +435,21 @@ export const isValidCreateMeter = createAppSelector( * @returns A tuple where the first element is a boolean indicating validity, and the second is a message string. */ export const selectIsValidCreateDailyPattern = createAppSelector( - [ - selectAllDays, - (_state, dailyPattern: { dayName: string }) => dailyPattern - ], - (days, dailyPattern): [boolean, string] => { - if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { - return [false, translate('daily.patterns.create.name.required')]; - } - // Check if dayName already exists (case-insensitive) - const exists = days.some(day => - day.dayName?.toLowerCase() === dailyPattern.dayName.trim().toLowerCase() - ); - if (exists) { - return [false, translate('daily.patterns.create.name.exists')]; - } - return [true, 'Daily Pattern is Valid']; - } + [ + selectAllDays, + (_state, dailyPattern: { dayName: string }) => dailyPattern + ], + (days, dailyPattern): [boolean, string] => { + if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { + return [false, translate('daily.patterns.create.name.required')]; + } + // Check if dayName already exists (case-insensitive) + const exists = days.some(day => + day.name?.toLowerCase() === dailyPattern.dayName.trim().toLowerCase() + ); + if (exists) { + return [false, translate('daily.patterns.create.name.exists')]; + } + return [true, 'Daily Pattern is Valid']; + } ); diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index 39b10738ce..f624743506 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,6 +1,6 @@ export interface Day { id: number; - dayName: string; + name: string; note: string; segments: DaySegment[]; } @@ -8,28 +8,29 @@ export interface Day { export interface DaySegment { id: number; dayId: number; - hour: number; + startHour: number; + endHour: number; slope: number; intercept: number; note?: string; } -export interface CreateDaySegmentPayload { - dayId: string; - hour: number; - slope: number; - intercept: number; - note?: string; -} +// export interface CreateDaySegmentPayload { +// dayId: string; +// hour: number; +// slope: number; +// intercept: number; +// note?: string; +// } -export interface UpdateDaySegmentPayload { - id: string; - hour: number; - slope: number; - intercept: number; - note?: string; -} +// export interface UpdateDaySegmentPayload { +// id: string; +// hour: number; +// slope: number; +// intercept: number; +// note?: string; +// } -export interface DeleteDaySegmentPayload { - id: string; -} +// export interface DeleteDaySegmentPayload { +// id: string; +// } diff --git a/src/server/models/DaySegment.js b/src/server/models/DaySegment.js index b2cafbbd42..b41736fb83 100644 --- a/src/server/models/DaySegment.js +++ b/src/server/models/DaySegment.js @@ -18,7 +18,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; @@ -60,7 +60,7 @@ class DaySegment { return rows.map(DaySegment.mapRow); } - /** + /** * 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. @@ -73,14 +73,14 @@ class DaySegment { return row === null ? null : DaySegment.mapRow(row); } - /** + /** * Returns the day segments associated with the day id. * @param {*} dayId The day pattern id. * @param {*} conn The connection to use. * @returns {Promise.} */ static async getByDayId(dayId, conn) { - const rows = await conn.any(sqlFile('daySegment/get_by_dayId.sql'), { + const rows = await conn.any(sqlFile('daySegment/get_by_day_id.sql'), { dayId: dayId }); return rows.map(DaySegment.mapRow) @@ -88,7 +88,7 @@ class DaySegment { /** * Returns a promise to insert the day segment. - * + * * @param {*} conn The connection to be used * @returns {Promise.} */ @@ -104,7 +104,7 @@ class DaySegment { // Commenting out to potentially use at a later time // /** // * Inserts a new day segment. Rebuilds surrounding segments to ensure full 00:00 - 24:00 coverage. - // * + // * // * @param {*} day_pattern_id The day pattern id the segment belongs to. // * @param {*} startHour The start hour of the segment (inclusive). // * @param {*} endHour The end hour of the segment (exclusive). @@ -144,7 +144,7 @@ class DaySegment { // intercept: seg.intercept, // note: seg.note // }); - // } + // } // // If the existing segment ends after the new segment, keep it's right portion // if (seg.endHour > endHour) { // await conn.none(insertSegment, { @@ -169,7 +169,7 @@ class DaySegment { // note: note // }); // } - + /** * Returns a promise to update an existing daySegment in the database. * @param conn the connection to use. @@ -195,4 +195,4 @@ class DaySegment { } } -module.exports = DaySegment; \ No newline at end of file +module.exports = DaySegment; From 4b4712ff17aab8a7216d9c0f6775781ef7d3e702 Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Wed, 30 Jul 2025 16:34:28 -0700 Subject: [PATCH 029/170] move ConversionSegmentData interface to separate file for modularity Signed-off-by: PRINCESANCHEZ --- src/client/app/types/redux/conversionSegments.ts | 14 ++++++++++++++ src/client/app/types/redux/conversions.ts | 11 ----------- 2 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 src/client/app/types/redux/conversionSegments.ts diff --git a/src/client/app/types/redux/conversionSegments.ts b/src/client/app/types/redux/conversionSegments.ts new file mode 100644 index 0000000000..3ccc79946c --- /dev/null +++ b/src/client/app/types/redux/conversionSegments.ts @@ -0,0 +1,14 @@ +/** + * 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 | null; + note: string; +} diff --git a/src/client/app/types/redux/conversions.ts b/src/client/app/types/redux/conversions.ts index 193aba72fe..7a3f245985 100644 --- a/src/client/app/types/redux/conversions.ts +++ b/src/client/app/types/redux/conversions.ts @@ -19,14 +19,3 @@ export interface ConversionsState { submitting: ConversionData[]; conversions: ConversionData[]; } - -export interface ConversionSegmentData { - sourceId: number; - destinationId: number; - startTime: string; - endTime: string; - slope: number; - intercept: number; - weekPatternsId: number | null; - note: string; -} From c595d1249509eb53fc9979c0d8333c327b4050e8 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Wed, 30 Jul 2025 21:49:37 -0700 Subject: [PATCH 030/170] feat(days): Edit Day feature TODO: internationalize strings in EditDailyPatternModalComponent and SplitDaySegmentComponent --- CreateDailyPatternModalComponent.tsx | 327 ----------------- DayDetailComponent.tsx | 65 ---- DayViewComponent.tsx | 71 ---- EditDailyPatternModalComponent.tsx | 328 ------------------ .../days/EditDailyPatternModalComponent.tsx | 314 +++++++---------- .../days/SplitDaySegmentComponent.tsx | 142 ++++++++ src/client/app/redux/api/daySegmentsApi.ts | 6 +- 7 files changed, 273 insertions(+), 980 deletions(-) delete mode 100644 CreateDailyPatternModalComponent.tsx delete mode 100644 DayDetailComponent.tsx delete mode 100644 DayViewComponent.tsx delete mode 100644 EditDailyPatternModalComponent.tsx create mode 100644 src/client/app/components/days/SplitDaySegmentComponent.tsx diff --git a/CreateDailyPatternModalComponent.tsx b/CreateDailyPatternModalComponent.tsx deleted file mode 100644 index b148395a95..0000000000 --- a/CreateDailyPatternModalComponent.tsx +++ /dev/null @@ -1,327 +0,0 @@ -/* 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 * 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 TooltipHelpComponent from '../TooltipHelpComponent'; -import { useAppSelector } from '../../redux/reduxHooks'; -import { selectDefaultCreateDailyPatternValues, selectIsValidCreateDailyPattern } from '../../redux/selectors/adminSelectors'; -import '../../styles/modal.css'; -import { tooltipBaseStyle } from '../../styles/modalStyle'; -import { useTranslate } from '../../redux/componentHooks'; -import TooltipMarkerComponent from '../TooltipMarkerComponent'; -import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; -import { daysApi } from '../../redux/api/daysApi'; - -/** - * Defines the create conversion modal form - * @returns Conversion create element - */ -export default function CreateDailyPatternModalComponent() { - const translate = useTranslate(); - const [addDailyPatternMutation] = daysApi.useAddDailyPatternMutation(); - - const defaultValues = useAppSelector(selectDefaultCreateDailyPatternValues); - - /* State */ - // Modal show - const [showModal, setShowModal] = useState(false); - - // State for the warning modal - const [showWarningModal, setShowWarningModal] = useState(false); - const [warningMessage, setWarningMessage] = useState(''); - - const handleClose = () => { - setShowModal(false); - resetState(); - }; - const handleShow = () => setShowModal(true); - - // Handlers for each type of input change - const [patternState, setPatternState] = useState({ - dailyPattern: { - dayName: defaultValues.dayName, - note: defaultValues.dailyPatternNote - }, - initialPattern: { - slope: defaultValues.slope, - intercept: defaultValues.intercept, - startHour: defaultValues.startHour, - endHour: defaultValues.endHour, - note: defaultValues.initialPatternNote - } -}); - - // Check if the daily pattern is valid - const [isValidDailyPattern, reason] = useAppSelector(state => - selectIsValidCreateDailyPattern(state, { - dayName: patternState.dailyPattern.dayName - // You can add more fields if your selector uses them - }) - ); - - const handleStringChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - if (name === 'dailyPatternNote') { - setPatternState(prev => ({ - ...prev, - dailyPattern: { - ...prev.dailyPattern, - note: value - } - })); - } else if (name === 'initialPatternNote') { - setPatternState(prev => ({ - ...prev, - initialPattern: { - ...prev.initialPattern, - note: value - } - })); - } else { - setPatternState(prev => ({ - ...prev, - dailyPattern: { - ...prev.dailyPattern, - dayName: value - } - })); - } - }; - - const handleNumberChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - const newValue = Number(value); - if (name === 'slope') { - setPatternState(prev => ({ - ...prev, - initialPattern: { - ...prev.initialPattern, - slope: newValue - } - })); - } else if (name === 'intercept') { - setPatternState(prev => ({ - ...prev, - initialPattern: { - ...prev.initialPattern, - intercept: newValue - } - })); - } - }; - - /* Warning Modal */ - const handleWarningConfirm = () => { - //Close the warning modal - setShowWarningModal(false); - // Add the new pattern and update the store - addDailyPatternMutation({ - dayName: patternState.dailyPattern.dayName, - slope: patternState.initialPattern.slope, - intercept: patternState.initialPattern.intercept, - note: patternState.dailyPattern.note, - segmentNote: patternState.initialPattern.note - }); - // Reset the state to default values - resetState(); - // Close the modal - setShowModal(false); - }; - - const handleWarningCancel = () => { - //Close the warning modal - setShowWarningModal(false); - }; - - // Reset the state to default values - const resetState = () => { - setPatternState({ - dailyPattern: { - dayName: defaultValues.dayName, - note: defaultValues.dailyPatternNote - }, - initialPattern: { - slope: defaultValues.slope, - intercept: defaultValues.intercept, - startHour: defaultValues.startHour, - endHour: defaultValues.endHour, - note: defaultValues.initialPatternNote - } - }); - }; - /* End Warning Modal */ - - // Submit - const handleSubmit = () => { - // Show warning modal if slope and intercept are both 0 - if (patternState.initialPattern.slope === 0 && patternState.initialPattern.intercept === 0) { - setWarningMessage(translate('daily.patterns.slope.intercept.zero')); - setShowWarningModal(true); - } else { - // Close modal first to avoid repeat clicks - setShowModal(false); - // Add the new pattern and update the store - addDailyPatternMutation({ - dayName: patternState.dailyPattern.dayName, - slope: patternState.initialPattern.slope, - intercept: patternState.initialPattern.intercept, - note: patternState.dailyPattern.note, - segmentNote: patternState.initialPattern.note - }); - // Reset the state to default values - resetState(); - } - }; - - const tooltipStyle = { - ...tooltipBaseStyle, - tooltipCreateConversionView: 'help.admin.conversioncreate' - }; - - return ( - <> - - {/* Show modal button */} - - - - - - -
- -
-
- {/* when any of the conversion are changed call one of the functions. */} - - - {/* Name for daily pattern*/} - - - handleStringChange(e)} - value={patternState.dailyPattern.dayName} - invalid={!patternState.dailyPattern.dayName || patternState.dailyPattern.dayName.trim() === ''} - required - /> - - - - - {/* Note input for overall conversion*/} - - - handleStringChange(e)} - value={patternState.dailyPattern.note} /> - - {/*Initial pattern*/} -
- -
- - - {/* Slope input*/} - - - handleNumberChange(e)} /> - - - - {/* Intercept input*/} - - - handleNumberChange(e)} /> - - - - - - {/* Start hour input*/} - - - - - - - {/* End hour input*/} - - - - - - - {/* Note input for initial pattern*/} - - - handleStringChange(e)} - value={patternState.initialPattern.note} /> - -
-
- - { - // Todo looks kind of bad make a better visible notification - !isValidDailyPattern &&

{reason}

- } - {/* Hides the modal */} - - {/* On click calls the function handleSaveChanges in this component */} - -
-
- - ); -} diff --git a/DayDetailComponent.tsx b/DayDetailComponent.tsx deleted file mode 100644 index 04eb1ba0ec..0000000000 --- a/DayDetailComponent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// DayDetailComponent.tsx -import * as React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { useAppSelector } from '../../redux/reduxHooks'; -import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; -import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; -import { Day } from '../../types/redux/days'; -import SpinnerComponent from '../SpinnerComponent'; -import TooltipHelpComponent from '../TooltipHelpComponent'; -import TooltipMarkerComponent from '../TooltipMarkerComponent'; -import DayViewComponent from './DayViewComponent'; -import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent'; -import { daysApi, stableEmptyDays } from '../../redux/api/daysApi'; - -/** - * Defines the daily pattern info page - * @param props defined above - * @returns Single day element - */ -export default function DayDetailComponent() { - const locale = useAppSelector(selectSelectedLanguage); - - // Day state - const { data: dayState = stableEmptyDays, isFetching: dayFetching } = daysApi.useGetDailyPatternsQuery(); - - const tooltipStyle = { - ...tooltipBaseStyle, - tooltipDayView: 'help.admin.dailypatternview' - }; - - return ( -
- {dayFetching ? ( -
- - -
- ) : ( -
- - -
-

- -
- -
-

-
- {/* Placeholder for create day modal */} - -
-
- {Object.values(dayState) - .sort((a, b) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) - .map(day => ( - - ))} -
-
-
- )} -
- ); -} \ No newline at end of file diff --git a/DayViewComponent.tsx b/DayViewComponent.tsx deleted file mode 100644 index 2b926bbbfb..0000000000 --- a/DayViewComponent.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// DayViewComponent.tsx -/* eslint-disable no-trailing-spaces */ -import * as React from 'react'; -// Realize that * is already imported from react -import { useState } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { Button } from 'reactstrap'; -import { Day } from 'types/redux/days'; -import '../../styles/card-page.css'; -//import { useTranslate } from '../../redux/componentHooks'; -import EditDailyPatternModalComponent from './EditDailyPatternModalComponent'; - -interface DayViewComponentProps { - day: Day; -} - -/** - * Defines the daily pattern info card - * @param props defined above - * @returns Single day element - */ -export default function DayViewComponent(props: DayViewComponentProps) { - //const translate = useTranslate(); - // Don't check if admin since only an admin is allow to route to this page. - - // Edit Modal Show - const [showEditModal, setShowEditModal] = useState(false); - //const unitDataById = useAppSelector(selectUnitDataById); - - const handleShow = () => { - setShowEditModal(true); - }; - - // const handleClose = () => { - // setShowEditModal(false); - // }; - - // Create header from sourceId, destinationId identifiers - //const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + - // unitDataById[props.conversion.destinationId]?.identifier); - const dayIdentifier = props.day.dayName || 'Unnamed Day'; - - function handleClose(): void { - throw new Error('Function not implemented.'); - } - - // Unlike the details component, we don't check if units are loaded since must come through that page. - - return ( -
-
- {dayIdentifier} -
-
- {/* 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 */} - -
-
- ); -} \ No newline at end of file diff --git a/EditDailyPatternModalComponent.tsx b/EditDailyPatternModalComponent.tsx deleted file mode 100644 index 10e904c9e7..0000000000 --- a/EditDailyPatternModalComponent.tsx +++ /dev/null @@ -1,328 +0,0 @@ -// EditDailyPatternModalComponent.tsx - -import * as React from 'react'; -import * as moment from 'moment-timezone'; -import { - Button, Container, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, - ModalHeader, Row, Col, Table, Pagination, PaginationItem, PaginationLink, - FormFeedback -} from 'reactstrap'; -import { Day, DaySegment } from 'types/redux/days'; - -// -export interface Props { - show: boolean; - day: Day; - handleClose: () => void; -} - -// Segment interface extends DaySegment to include id and date range -interface Segment extends DaySegment { - id: number; - start: string; // ISO | '-inf' - end: string; // ISO | '+inf' -} - -const PER_PAGE = 10; - -/* spec‑accurate range text */ -const tsFmt = (iso: string) => - moment.parseZone(iso).format('MMMM D, YYYY hh:mm:ss A'); - -const dateRange = (s: Segment) => - s.start === '-inf' - ? `to ${tsFmt(s.end)}` - : s.end === '+inf' - ? `from ${tsFmt(s.start)}` - : `${tsFmt(s.start)} to ${tsFmt(s.end)}`; - -// Main component for editing daily patterns -// Displays a modal with a table of segments and options to edit, split, or delete them -export default function EditDailyPatternModalComponent({ show, day, handleClose }: Props) { - - const [segments, setSegments] = React.useState( - (day.segments as unknown as Segment[]) ?? [] - ); - - /* --- TEMP DEMO ROWS -------------------------------------------------- */ - React.useEffect(() => { - if (segments.length === 0) { - setSegments([ - /* 1 ─ full dates, No Pattern */ - { - id: 1, - start: '2024-03-09T00:00:00Z', - end: '2025-01-02T00:00:00Z', - slope: 123.45, - intercept: 0, - pattern: 'No Pattern', - note: 'Sample with full dates and no pattern', - dayId: 0, - hour: 0 - }, - /* 2 ─ full dates, Weekly ABC */ - { - id: 2, - start: '2024-03-09T00:00:00Z', - end: '2025-01-02T00:00:00Z', - slope: 0, - intercept: 0, - pattern: 'Weekly ABC', - note: 'Sample with full dates and pattern', - dayId: 0, - hour: 0 - }, - /* 3 ─ from -∞ to a date */ - { - id: 3, - start: '-inf', - end: '2025-01-02T00:00:00Z', - slope: 123.45, - intercept: 0, - pattern: 'No Pattern', - note: 'Sample where it applies from -inf and no pattern', - dayId: 0, - hour: 0 - }, - /* 4 ─ from a date to +∞, Weekly XYZ */ - { - id: 4, - start: '2025-01-02T00:00:00Z', - end: '+inf', - slope: 0, - intercept: 0, - pattern: 'Weekly XYZ', - note: 'Sample where it applies to +inf and pattern', - dayId: 0, - hour: 0 - } - ]); - } - - }, []); - - - // Pagination - const [page, setPage] = React.useState(1); - const totalPages = Math.ceil(segments.length / PER_PAGE); - const paged = segments.slice((page - 1) * PER_PAGE, page * PER_PAGE); - - // Editing state - const [editing, setEditing] = React.useState(null); - const [split, setSplit] = React.useState<{ - seg: Segment | null; - dir: 'earlier' | 'later' | null; - dt: string; - err: string; - }>({ seg: null, dir: null, dt: '', err: '' }); - - // Update a segment field - const upd = (id: number, field: keyof Segment, val: any) => - setSegments(prev => prev.map(s => (s.id === id ? { ...s, [field]: val } : s))); - - const deletePart = (seg: Segment, dir: 'earlier' | 'later') => { - const mid = moment(seg.start).add( - moment(seg.end).diff(moment(seg.start)) / 2 - ).toISOString(); - if (dir === 'earlier') upd(seg.id, 'start', mid); - else upd(seg.id, 'end', mid); - }; - - const splitNow = () => { - if (!split.seg) return; - const dt = moment(split.dt, 'YYYY-MM-DD HH:mm:ss', true); - if ( - !dt.isValid() || - !dt.isBetween(moment(split.seg.start), moment(split.seg.end)) - ) { - setSplit(m => ({ ...m, err: 'Must be inside current range' })); - return; - } - const first = { ...split.seg, end: dt.toISOString() }; - const second = { ...split.seg, start: dt.toISOString() }; - setSegments(prev => - prev - .filter(s => s.id !== split.seg!.id) - .concat(split.dir === 'earlier' ? [first, second] : [second, first]) - .sort((a, b) => moment(a.start).valueOf() - moment(b.start).valueOf()) - ); - setSplit({ seg: null, dir: null, dt: '', err: '' }); - }; - - /* ── Render ───────────────────────────────────────────── */ - return ( - <> - - Details/Edit Day - - - - {/* Name and Note */} - - - - - - - - - - - - - - - - {/* Table */} -
Conversion Table
- - - - - - - - - - {paged.map(seg => ( - - - - - - - - - - - - - ))} - -
Date RangeSlopeInterceptPatternNoteEditSplit ↑Split ↓Del ↑Del ↓
{dateRange(seg)}{seg.pattern === 'No Pattern' ? seg.slope : ''}{seg.pattern === 'No Pattern' ? seg.intercept : ''}{seg.pattern} - {(seg.note ?? '').length > 100 - ? (seg.note ?? '').slice(0, 100) + ' …' - : seg.note ?? ''} -
- - {/* Pagination */} - {segments.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)} /> - - - )} -
-
- - - - - -
- - {/* Edit‑segment modal */} - {editing && ( - setEditing(null)}> - Edit Segment - -

{dateRange(editing)}

- - - setEditing({ ...editing, slope: +e.target.value })} - disabled={editing.pattern !== 'No Pattern'} - /> - - - - setEditing({ ...editing, intercept: +e.target.value })} - disabled={editing.pattern !== 'No Pattern'} - /> - - - - setEditing({ ...editing, pattern: e.target.value })} - > - - - - - - - - setEditing({ ...editing, note: e.target.value })} - /> - -
- - - - -
- )} - - {/* Split modal */} - {split.seg && ( - setSplit({ seg: null, dir: null, dt: '', err: '' })}> - - Split {split.dir === 'earlier' ? 'Earlier ↑' : 'Later ↓'} - - -

- Enter datetime (YYYY‑MM‑DD HH:mm:ss) between
- {dateRange(split.seg)} -

- setSplit(m => ({ ...m, dt: e.target.value, err: '' }))} - invalid={Boolean(split.err)} - /> - {split.err && {split.err}} -
- - - - -
- )} - - ); -} diff --git a/src/client/app/components/days/EditDailyPatternModalComponent.tsx b/src/client/app/components/days/EditDailyPatternModalComponent.tsx index a5cf2c7f3b..b127788254 100644 --- a/src/client/app/components/days/EditDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/EditDailyPatternModalComponent.tsx @@ -1,4 +1,6 @@ -// EditDailyPatternModalComponent.tsx +/* 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'; @@ -19,36 +21,27 @@ import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; import { LocaleDataKey } from '../../translations/data'; import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; import translate from '../../utils/translate'; +import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import EditDaySegmentModalComponent from './EditDaySegmentModalComponent'; +import SplitDaySegmentComponent from './SplitDaySegmentComponent'; -// -export interface Props { +export interface EditDailyPatternModalComponentProps { + /** + * Whether the modal is visible or not + */ show: boolean; + /** + * The day to edit + */ day: Day; + /** + * Function to run when edit modal closes + */ handleClose: () => void; } -// Segment interface extends DaySegment to include id and date range -// interface Segment extends DaySegment { -// id: number; -// start: string; // ISO | '-inf' -// end: string; // ISO | '+inf' -// pattern: string; -// } - const PER_PAGE = 10; -/* spec‑accurate range text */ -// const tsFmt = (iso: string) => -// moment.parseZone(iso).format('MMMM D, YYYY hh:mm:ss A'); - -// const dateRange = (s: DaySegment) => -// s.start === '-inf' -// ? `to ${tsFmt(s.end)}` -// : s.end === '+inf' -// ? `from ${tsFmt(s.start)}` -// : `${tsFmt(s.start)} to ${tsFmt(s.end)}`; - /** * Given an hour in range [0, 24], returns the time in format HH:MM{AM|PM} * @param hour The hour to transform @@ -63,164 +56,120 @@ function hourToTime(hour: number) { return `${displayHour}:00 ${suffix}`; } -// Main component for editing daily patterns -// Displays a modal with a table of segments and options to edit, split, or delete them -export default function EditDailyPatternModalComponent({ show, day, handleClose }: Props) { +/** + * 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 EditDailyPatternModalComponent(props: EditDailyPatternModalComponentProps) { - // const [segments, setSegments] = React.useState( - // (day.segments as unknown as Segment[]) ?? [] - // ); + const [dayDetails, setDayDetails] = React.useState({ ...props.day }); - const [dayDetails, setDayDetails] = React.useState({ ...day }); + const { data: daySegments } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(props.day.id); - const { data: daySegments, isFetching: isFetchingDaySegments } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(day.id); + // Sort day segments by start hour to display them in the correct order in the table + const sortedDaySegments = React.useMemo(() => { + if (!daySegments) { + return []; + } + return [...daySegments].sort((a, b) => a.startHour - b.startHour); + }, [daySegments]); - const [editDayMutation, { isLoading: isSaving }] = daysApi.useEditDailyPatternMutation(); + // Fetch days data (used to check if day name already exists) + const { data: days } = daysApi.useGetDailyPatternsQuery(); - const [addDaySegmentMutation, { isLoading: isAddingDaySegment }] = daySegmentsApi.useAddDailyPatternSegmentMutation(); + const [editDayMutation, { isLoading: isSaving }] = daysApi.useEditDailyPatternMutation(); const handleStringChange = (e: React.ChangeEvent) => { setDayDetails({ ...dayDetails, [e.target.name]: e.target.value }); }; const handleSubmit = () => { + if (!isDayValid) { + return; + } + editDayMutation(dayDetails).unwrap() .then(() => { // TODO: internationalize showSuccessNotification('Successfully edited day.'); - handleClose(); + props.handleClose(); }).catch(error => { // TODO: internationalize showErrorNotification(error); }); }; - /* --- TEMP DEMO ROWS -------------------------------------------------- */ - // React.useEffect(() => { - // if (segments.length === 0) { - // setSegments([ - // /* 1 ─ full dates, No Pattern */ - // { - // id: 1, - // start: '2024-03-09T00:00:00Z', - // end: '2025-01-02T00:00:00Z', - // slope: 123.45, - // intercept: 0, - // pattern: 'No Pattern', - // note: 'Sample with full dates and no pattern', - // dayId: 0, - // hour: 0 - // }, - // /* 2 ─ full dates, Weekly ABC */ - // { - // id: 2, - // start: '2024-03-09T00:00:00Z', - // end: '2025-01-02T00:00:00Z', - // slope: 0, - // intercept: 0, - // pattern: 'Weekly ABC', - // note: 'Sample with full dates and pattern', - // dayId: 0, - // hour: 0 - // }, - // /* 3 ─ from -∞ to a date */ - // { - // id: 3, - // start: '-inf', - // end: '2025-01-02T00:00:00Z', - // slope: 123.45, - // intercept: 0, - // pattern: 'No Pattern', - // note: 'Sample where it applies from -inf and no pattern', - // dayId: 0, - // hour: 0 - // }, - // /* 4 ─ from a date to +∞, Weekly XYZ */ - // { - // id: 4, - // start: '2025-01-02T00:00:00Z', - // end: '+inf', - // slope: 0, - // intercept: 0, - // pattern: 'Weekly XYZ', - // note: 'Sample where it applies to +inf and pattern', - // dayId: 0, - // hour: 0 - // } - // ]); - // } - - // }, []); - - // Pagination const [page, setPage] = React.useState(1); - const totalPages = Math.ceil(daySegments?.length ?? 1 / PER_PAGE); - const paged = daySegments?.slice((page - 1) * PER_PAGE, page * PER_PAGE); - - // Editing state - // const [editing, setEditing] = React.useState(null); - const [split, setSplit] = React.useState<{ - seg: DaySegment | null; - dir: 'earlier' | 'later' | null; - dt: string; - err: string; - }>({ seg: null, dir: null, dt: '', err: '' }); + const totalPages = Math.ceil(sortedDaySegments?.length ?? 1 / PER_PAGE); + const paged = sortedDaySegments?.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 isDayValid = React.useMemo(() => { if (dayDetails.name === '') { - setNameValidationMessageId('error.required'); + setNameValidationMessageId('daily.patterns.create.name.required'); + return false; + } + if (days?.some(day => day.name.toLowerCase() === dayDetails.name.toLowerCase() && day.id !== dayDetails.id)) { + setNameValidationMessageId('daily.patterns.create.name.exists'); return false; } + setNameValidationMessageId(null); return true; }, [dayDetails]); - const [deleteDayMutation, { isLoading: isDeleting }] = daysApi.useDeleteDailyPatternMutation(); - // Update a segment field - // const upd = (id: number, field: keyof DaySegment, val: any) => - // setSegments(prev => prev.map(s => (s.id === id ? { ...s, [field]: val } : s))); + const [editDaySegmentMutation] = daySegmentsApi.useEditDailyPatternSegmentMutation(); + const [deleteDaySegmentMutation] = daySegmentsApi.useDeleteDailyPatternSegmentMutation(); + + // Function to handle deleting a segment + // Adjusts the neighboring segment's start or end hour accordingly + const handleDeleteSegment = (seg: DaySegment, direction: 'earlier' | 'later') => { + const index = sortedDaySegments.findIndex(s => s.id === seg.id); + let targetSegment = direction === 'earlier' ? sortedDaySegments[index - 1] : sortedDaySegments[index + 1]; + targetSegment = { + ...targetSegment, + startHour: direction === 'later' ? seg.startHour : targetSegment.startHour, + endHour: direction === 'earlier' ? seg.endHour : targetSegment.endHour + }; + const editPromise = editDaySegmentMutation(targetSegment).unwrap(); + const deletePromise = deleteDaySegmentMutation({ id: seg.id }).unwrap(); + + Promise.all([editPromise, deletePromise]) + .catch(error => { + showErrorNotification(error); + }); + }; - // const deletePart = (seg: DaySegment, dir: 'earlier' | 'later') => { - // const mid = moment(seg.start).add( - // moment(seg.end).diff(moment(seg.start)) / 2 - // ).toISOString(); - // if (dir === 'earlier') upd(seg.id, 'start', mid); - // else upd(seg.id, 'end', mid); - // }; + // Delete day confirmation modal + const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [deleteDayMutation, { isLoading: isDeleting }] = daysApi.useDeleteDailyPatternMutation(); - // const splitNow = () => { - // if (!split.seg) return; - // const dt = moment(split.dt, 'YYYY-MM-DD HH:mm:ss', true); - // if ( - // !dt.isValid() || - // !dt.isBetween(moment(split.seg.start), moment(split.seg.end)) - // ) { - // setSplit(m => ({ ...m, err: 'Must be inside current range' })); - // return; - // } - // const first = { ...split.seg, end: dt.toISOString() }; - // const second = { ...split.seg, start: dt.toISOString() }; - // setSegments(prev => - // prev - // .filter(s => s.id !== split.seg!.id) - // .concat(split.dir === 'earlier' ? [first, second] : [second, first]) - // .sort((a, b) => moment(a.start).valueOf() - moment(b.start).valueOf()) - // ); - // setSplit({ seg: null, dir: null, dt: '', err: '' }); - // }; + // Function to handle deleting the day + const handleDeleteDay = () => { + setShowDeleteModal(false); + deleteDayMutation({ id: dayDetails.id }).unwrap() + .then(() => { + // TODO: internationalize + showSuccessNotification('Successfully deleted day.'); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(error); + }); + }; /* ── Render ───────────────────────────────────────────── */ return ( <> - - {/* TODO: internationalize */} - + + @@ -251,15 +200,16 @@ export default function EditDailyPatternModalComponent({ show, day, handleClose - + {/* TODO: internationalize */} + - - - - + + + + @@ -277,22 +227,27 @@ export default function EditDailyPatternModalComponent({ show, day, handleClose ))} @@ -300,7 +255,7 @@ export default function EditDailyPatternModalComponent({ show, day, handleClose
Date RangeTime Range Slope Intercept Note EditSplit ↑Split ↓Del ↑Del ↓Del ↑Del ↓
- + {/* only show split buttons if segment is longer than 1 hour */} + {seg.endHour - seg.startHour > 1 && } - + {seg.endHour - seg.startHour > 1 && } - + {/* first segment cannout delete earlier */} + {seg.startHour > 0 && + + } - + {/* last segment cannot delete later */} + {seg.endHour < 24 && + + }
{/* Pagination */} - {daySegments && daySegments.length > PER_PAGE && ( + {sortedDaySegments && sortedDaySegments.length > PER_PAGE && ( setPage(1)} /> @@ -325,48 +280,31 @@ export default function EditDailyPatternModalComponent({ show, day, handleClose - + -
- - - {/* Split modal */} - { - split.seg && ( - setSplit({ seg: null, dir: null, dt: '', err: '' })}> - - Split {split.dir === 'earlier' ? 'Earlier ↑' : 'Later ↓'} - - -

- Enter datetime (YYYY‑MM‑DD HH:mm:ss) between
- {/* {dateRange(split.seg)} */} -

- setSplit(m => ({ ...m, dt: e.target.value, err: '' }))} - invalid={Boolean(split.err)} - /> - {split.err && {split.err}} -
- - - - -
- ) - } + {/* Delete confirmation modal */} + setShowDeleteModal(false)} + // TODO: internationalize + actionConfirmText={'Delete Day'} + actionRejectText={translate('cancel')} + /> +
); } diff --git a/src/client/app/components/days/SplitDaySegmentComponent.tsx b/src/client/app/components/days/SplitDaySegmentComponent.tsx new file mode 100644 index 0000000000..dd7a91c686 --- /dev/null +++ b/src/client/app/components/days/SplitDaySegmentComponent.tsx @@ -0,0 +1,142 @@ +/* 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, Input, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; +import { DaySegment } from '../../types/redux/days'; +import { showErrorNotification } from '../../utils/notifications'; +import translate from '../../utils/translate'; + +/** + * Convert an integer [0, 24] to standard time value format to be used by the time input. + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/time#time_value_format + * @param hour The integer hour to convert + * @returns a time string in the format "HH:mm" + */ +// function hourToTimeFormat(hour: number) { +// const clamped = Math.max(0, Math.min(24, hour)); +// const hh = clamped.toString().padStart(2, '0'); +// return `${hh}:00`; +// } + +/** + * Converts a time string in the format "HH:mm" to an integer hour. + * @param timeStr The time string to convert (e.g., "09:00") + * @returns The integer hour (e.g., 9) + */ +// function timeFormatToHour(timeStr: string): number { +// const [hh] = timeStr.split(':').map(Number); +// // Only supports full hours (mm === 0) +// return hh; +// } + +interface SplitDaySegmentComponentProps { + direction: 'earlier' | 'later'; + daySegment: DaySegment; +} + +export default function SplitDaySegmentComponent(props: SplitDaySegmentComponentProps): React.ReactElement { + + // const [daySegment, setDaySegment] = React.useState({ ...props.daySegment }); + + const [splitHour, setSplitHour] = React.useState(props.daySegment.startHour + 1); + + const [showEditModal, setShowEditModal] = React.useState(false); + + const [editDaySegmentMutation, { isLoading: isEditSaving }] = daySegmentsApi.useEditDailyPatternSegmentMutation(); + + const [addDaySegmentMutation, { isLoading: isAddSaving }] = daySegmentsApi.useAddDailyPatternSegmentMutation(); + + const handleShowEditModal = () => setShowEditModal(true); + const handleHideEditModal = () => setShowEditModal(false); + + const handleSplitInputChange = (e: React.ChangeEvent) => { + setSplitHour(Number(e.target.value)); + }; + + 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 handleSplitSegment = () => { + if (!isSplitValid) { + return; + } + + const editSegment: DaySegment = { + ...props.daySegment, + startHour: props.direction === 'earlier' ? splitHour : props.daySegment.startHour, + endHour: props.direction === 'earlier' ? props.daySegment.endHour : splitHour + }; + const newSegment: Omit = { + dayId: props.daySegment.dayId, + slope: 0, + intercept: 0, + startHour: props.direction === 'earlier' ? props.daySegment.startHour : splitHour, + endHour: props.direction === 'earlier' ? splitHour : props.daySegment.endHour + }; + + const editPromise = editDaySegmentMutation(editSegment).unwrap(); + const addPromise = addDaySegmentMutation(newSegment).unwrap(); + + Promise.all([editPromise, addPromise]) + .then(() => { + handleHideEditModal(); + }) + .catch(error => { + showErrorNotification(error); + }); + }; + + return ( + <> + + + + + + + + {/* TODO: internationalize */} +

Enter the hour to split the segment

+ + + {/* TODO: internationalize */} + {!isSplitValid && 'Please enter a valid hour between ' + (props.daySegment.startHour + 1) + translate('and') + (props.daySegment.endHour - 1)} + +
+ + + + +
+ + ); +} + diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts index fd210cb13c..0ca6d92e67 100644 --- a/src/client/app/redux/api/daySegmentsApi.ts +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -1,5 +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/. */ + import { createSelector } from '@reduxjs/toolkit'; -import { DaySegment } from '../../types/redux/days'; // Adjust path as needed +import { DaySegment } from '../../types/redux/days'; import { baseApi } from './baseApi'; // TODO: fix tags and invalidation From cc7483323bde3a1a4538e730ebc5c31fdfa30490 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Wed, 30 Jul 2025 21:58:41 -0700 Subject: [PATCH 031/170] chore: dead code and comments cleanup --- .../days/SplitDaySegmentComponent.tsx | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/client/app/components/days/SplitDaySegmentComponent.tsx b/src/client/app/components/days/SplitDaySegmentComponent.tsx index dd7a91c686..650e1f5b52 100644 --- a/src/client/app/components/days/SplitDaySegmentComponent.tsx +++ b/src/client/app/components/days/SplitDaySegmentComponent.tsx @@ -10,38 +10,26 @@ import { DaySegment } from '../../types/redux/days'; import { showErrorNotification } from '../../utils/notifications'; import translate from '../../utils/translate'; -/** - * Convert an integer [0, 24] to standard time value format to be used by the time input. - * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/time#time_value_format - * @param hour The integer hour to convert - * @returns a time string in the format "HH:mm" - */ -// function hourToTimeFormat(hour: number) { -// const clamped = Math.max(0, Math.min(24, hour)); -// const hh = clamped.toString().padStart(2, '0'); -// return `${hh}:00`; -// } - -/** - * Converts a time string in the format "HH:mm" to an integer hour. - * @param timeStr The time string to convert (e.g., "09:00") - * @returns The integer hour (e.g., 9) - */ -// function timeFormatToHour(timeStr: string): number { -// const [hh] = timeStr.split(':').map(Number); -// // Only supports full hours (mm === 0) -// return hh; -// } - 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 [daySegment, setDaySegment] = React.useState({ ...props.daySegment }); - const [splitHour, setSplitHour] = React.useState(props.daySegment.startHour + 1); const [showEditModal, setShowEditModal] = React.useState(false); From 92dfe3aaff2b9cd2649a6a0fc5e106b8025c03f3 Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Thu, 31 Jul 2025 14:49:59 -0700 Subject: [PATCH 032/170] cleanup modal state, add multi-field error handling, update API endpoints with endTime, improve save logic for handling changes to time range Signed-off-by: PRINCESANCHEZ --- .../EditConversionModalComponent.tsx | 135 ++++++++++++------ .../app/redux/api/conversionSegmentsApi.ts | 31 ++-- 2 files changed, 109 insertions(+), 57 deletions(-) diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index 284f372998..3d2e1741ea 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -18,7 +18,8 @@ import { useAppSelector } from '../../redux/reduxHooks'; import '../../styles/modal.css'; import { tooltipBaseStyle } from '../../styles/modalStyle'; import { TrueFalseType } from '../../types/items'; -import { ConversionData, ConversionSegmentData } from '../../types/redux/conversions'; +import { ConversionData } from '../../types/redux/conversions'; +import { ConversionSegmentData } from '../../types/redux/conversionSegments'; import { UnitData, UnitType } from '../../types/redux/units'; import { useTranslate } from '../../redux/componentHooks'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; @@ -69,15 +70,19 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Handlers for each type of input change const [state, setState] = useState(values); const [editingSegment, setEditingSegment] = useState(null); - const [showEditSegmentModal, setShowEditSegmentModal] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [modalHeader, setModalHeader] = useState(''); - const [modalNote, setModalNote] = useState(''); - const [showSplitModal, setShowSplitModal] = useState(false); + const [showSegmentNoteModal, setShowSegmentNoteModal] = React.useState(false); + const [showEditSegmentModal, setShowEditSegmentModal] = React.useState(false); + const [showSplitSegmentModal, setShowSplitSegmentModal] = React.useState(false); const [actionDirection, setActionDirection] = useState<'earlier' | 'later' | null>(null); const [selectedSegment, setSelectedSegment] = useState(null); const [actionDatetime, setActionDatetime] = useState(''); - const [datetimeError, setDatetimeError] = useState(''); + const [fieldErrors, setFieldErrors] = useState<{ + startTimeError?: string; + endTimeError?: string; + splitTimeError?: string; + segmentValidationError?: string; + dateTimeError?: string; + }>({}); const [currentPage, setCurrentPage] = React.useState(1); const [showAllSegments, setShowAllSegments] = React.useState(false); const totalPages = Math.ceil(segments.length / PER_TABLE); @@ -127,10 +132,9 @@ export default function EditConversionModalComponent(props: EditConversionModalC }; // Opens a modal showing the full segment note - const handleNoteModal = (start: string, end: string, note: string) => { - setModalHeader(`${moment.parseZone(start).format('LL LTS [(and ]SSS[ms)]')} to ${moment.parseZone(end).format('LL LTS [(and ]SSS[ms)]')}`); - setModalNote(note); - setModalOpen(true); + const handleNoteModal = (segment: ConversionSegmentData) => { + setSelectedSegment(segment); + setShowSegmentNoteModal(true); }; // Handles and validates changes to datetime fields (start, end, or split) @@ -138,9 +142,9 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Validate datetime format const validFormat = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; if (!validFormat.test(e.target.value.trim())) { - setDatetimeError(`Invalid ${e.target.name} format. Use YYYY-MM-DD HH:mm:ss`); + setFieldErrors(prev => ({ ...prev, dateTimeError: `Invalid ${e.target.name} format. Use YYYY-MM-DD HH:mm:ss` })); } else { - setDatetimeError(''); + setFieldErrors({}); } // Prevent editing of startTime or endTime if the current value is -infinity or infinity, // since these represent unbounded segments and should not be user-editable @@ -172,12 +176,13 @@ export default function EditConversionModalComponent(props: EditConversionModalC const handleSplitSegment = async () => { if (!selectedSegment || !actionDirection || !actionDatetime) return; - const splitMoment = moment(actionDatetime); - const startMoment = moment(selectedSegment.startTime); - const endMoment = moment(selectedSegment.endTime); + const splitTime = moment(actionDatetime); + const isStartTimeValid = selectedSegment.startTime === '-infinity' || splitTime.isAfter(moment(selectedSegment.startTime)); + const isEndTimeValid = selectedSegment.endTime === 'infinity' || splitTime.isBefore(moment(selectedSegment.endTime)); - if (!splitMoment.isAfter(startMoment) || !splitMoment.isBefore(endMoment)) { - setDatetimeError('Split time must be strictly between the segment start and end.'); + + if (!isStartTimeValid || !isEndTimeValid) { + setFieldErrors(prev => ({ ...prev, splitTimeError: 'Split time must be strictly between the segment start and end.' })); return; } @@ -232,7 +237,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC note: '' }); } - setShowSplitModal(false); + setShowSplitSegmentModal(false); setSelectedSegment(null); setActionDirection(null); setActionDatetime(''); @@ -477,15 +482,47 @@ export default function EditConversionModalComponent(props: EditConversionModalC // If the editing segment has no slope, no intercept, and no pattern applied, // show a warning modal to confirm saving a "neutral" segment with no effect. if (editingSegment && editingSegment.slope === 0 && editingSegment.intercept === 0 && editingSegment.weekPatternsId === null) { - setWarningMessage('All segments have 0 slope and intercept. Are you sure?'); - setShowWarningModal(true); + setFieldErrors(prev => ({ ...prev, segmentValidationError: 'Segment has 0 slope and intercept and no pattern!' })); return; } + // If editing a segment's time range, ensure there are no gaps or overlaps with adjacent segments, + // but only if the range is bounded (i.e., not -infinity or infinity). + if (editingSegment) { + // Sort segments by start time + const sortedSegments = [...segments].sort((a, b) => { + const segmentAStart = a.startTime === '-infinity' ? null : moment(a.startTime); + const segmentBStart = b.startTime === '-infinity' ? null : moment(b.startTime); + if (!segmentAStart) return -1; + if (!segmentBStart) return 1; + return segmentAStart.isBefore(segmentBStart) ? -1 : 1; + }); + // Find the index of the segment being edited + const index = sortedSegments.findIndex( + seg => seg.startTime === editingSegment.originalStartTime && seg.endTime === editingSegment.originalEndTime + ); + // Grab previous and next segments if they exist + const previous = sortedSegments[index - 1]; + const next = sortedSegments[index + 1]; + // Check for gaps/overlaps with previous segment + if (previous && !moment(editingSegment.startTime).isSame(previous.endTime)) { + setFieldErrors(prev =>({ ...prev, startTimeError: `Start time must match end time of previous segment (${previous.endTime})` })); + return; + } + // Check for gaps/overlaps with next segment + if (next && !moment(editingSegment.endTime).isSame(next.startTime)) { + setFieldErrors(prev =>({ ...prev, endTimeError: `End time must match start time of next segment (${next.startTime})` })); + return; + } + } // If a segment is being edited, send the updated data to the backend // and close the edit segment modal if the request succeeds. if (editingSegment) { try { - await editSegment(editingSegment); + await editSegment({ + segment: editingSegment, + originalStartTime: editingSegment.originalStartTime, + originalEndTime: editingSegment.originalEndTime + }); setShowEditSegmentModal(false); } catch (error) { console.error('Failed to edit segment:', error); @@ -538,12 +575,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC actionConfirmText={deleteRejectText} actionRejectText={deleteRejectText} forceCancel={true} /> - setModalOpen(false)} centered> - setModalOpen(false)}> - {modalHeader} + setShowSegmentNoteModal(false)} centered> + setShowSegmentNoteModal(false)}> + {selectedSegment?.startTime} to {selectedSegment?.endTime} - {modalNote} + {selectedSegment?.note} {showEditSegmentModal && ( @@ -560,10 +597,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC placeholder='YYYY-MM-DD HH:MM:SS' value={editingSegment?.startTime ?? ''} onChange={handleDatetimeChange} - invalid={datetimeError !== ''} + invalid={!!fieldErrors.startTimeError} disabled={editingSegment?.startTime === '-infinity'} /> - {datetimeError && {datetimeError}} + {fieldErrors.startTimeError} + + - {datetimeError && {datetimeError}} + {fieldErrors.endTimeError} @@ -583,7 +622,9 @@ export default function EditConversionModalComponent(props: EditConversionModalC type='number' value={editingSegment?.slope ?? ''} onChange={handleNumberChange} + invalid={!!fieldErrors.segmentValidationError} /> + {fieldErrors.segmentValidationError} @@ -592,7 +633,9 @@ export default function EditConversionModalComponent(props: EditConversionModalC type='number' value={editingSegment?.intercept ?? ''} onChange={handleNumberChange} + invalid={!!fieldErrors.segmentValidationError} /> + {fieldErrors.segmentValidationError} @@ -601,12 +644,15 @@ export default function EditConversionModalComponent(props: EditConversionModalC name='pattern' type='select' value={editingSegment?.weekPatternsId ?? ''} - onChange={handlePatternChange}> + onChange={handlePatternChange} + invalid={!!fieldErrors.segmentValidationError} + > {weekPatterns.map(pattern => ( ))} + {fieldErrors.segmentValidationError} @@ -619,17 +665,14 @@ export default function EditConversionModalComponent(props: EditConversionModalC - )} - {showSplitModal && ( - setShowSplitModal(false)}> + {showSplitSegmentModal && ( + setShowSplitSegmentModal(false)}> @@ -643,18 +686,15 @@ export default function EditConversionModalComponent(props: EditConversionModalC value={actionDatetime} placeholder='YYYY-MM-DD HH:MM:SS' onChange={handleDatetimeChange} - invalid={datetimeError !== ''} + invalid={!!fieldErrors.splitTimeError} /> - {datetimeError && {datetimeError}} + {fieldErrors.splitTimeError} - - @@ -771,7 +811,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC {weekPatterns.find(wp => wp.id === segment.weekPatternsId)?.weekName ?? ''} handleNoteModal(segment.startTime, segment.endTime, segment.note)} + onClick={() => handleNoteModal(segment)} > {(segment.note ?? '').length > 100 ? `${segment.note.slice(0, 100)} ...` : segment.note || ''} @@ -782,6 +822,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC originalStartTime: segment.startTime, originalEndTime: segment.endTime }); + setFieldErrors({}); setShowEditSegmentModal(true); }}> @@ -793,7 +834,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC setActionDirection('earlier'); setSelectedSegment(segment); setActionDatetime(''); - setShowSplitModal(true); + setShowSplitSegmentModal(true); }} > @@ -803,7 +844,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC setActionDirection('later'); setSelectedSegment(segment); setActionDatetime(''); - setShowSplitModal(true); + setShowSplitSegmentModal(true); }} > diff --git a/src/client/app/redux/api/conversionSegmentsApi.ts b/src/client/app/redux/api/conversionSegmentsApi.ts index f37e7bcaca..a4a2e3a28f 100644 --- a/src/client/app/redux/api/conversionSegmentsApi.ts +++ b/src/client/app/redux/api/conversionSegmentsApi.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { baseApi } from './baseApi'; -import { ConversionSegmentData } from '../../types/redux/conversions'; +import { ConversionSegmentData } from '../../types/redux/conversionSegments'; /** * This file defines the segmentsApi using RTK Query. @@ -29,23 +29,34 @@ export const conversionSegmentsApi = baseApi.injectEndpoints({ transformResponse: (response: ConversionSegmentData[]) => Array.isArray(response) ? response : response ? [response] : [] }), - getConversionSegmentByKey: builder.query({ - query: ({ sourceId, destinationId, startTime }) => ({ + getConversionSegmentByKey: builder.query({ + query: ({ sourceId, destinationId, startTime, endTime }) => ({ url: '/api/conversionSegments/segment', method: 'POST', - body: { sourceId, destinationId, startTime } + body: { sourceId, destinationId, startTime, endTime } }), - providesTags: (result, error, segment) => [{ type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}-${segment.startTime}` }] + providesTags: (result, error, segment) => [{ + type: 'ConversionSegments', + id: `${segment.sourceId}-${segment.destinationId}-${segment.startTime}-${segment.endTime}` + }] }), - editConversionSegment: builder.mutation({ - query: segment => ({ + editConversionSegment: builder.mutation({ + query: ({ segment, originalStartTime, originalEndTime }) => ({ url: '/api/conversionSegments/edit', method: 'POST', - body: segment + body: { + ...segment, + originalStartTime, + originalEndTime + } }), - invalidatesTags: (result, error, segment) => [ + invalidatesTags: (result, error, { segment, originalStartTime }) => [ { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}` }, - { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}-${segment.startTime}` } + { type: 'ConversionSegments', id: `${segment.sourceId}-${segment.destinationId}-${originalStartTime}` } ] }), addConversionSegment: builder.mutation({ From 3ae7ab70b95ea45207b5fa7e2134263820a57945 Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Thu, 31 Jul 2025 22:13:57 -0700 Subject: [PATCH 033/170] Add delete logic + i18n for field errors Signed-off-by: PRINCESANCHEZ --- .../EditConversionModalComponent.tsx | 114 +++++++++++++++--- src/client/app/translations/data.ts | 15 +++ 2 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index 3d2e1741ea..b33b29bbe3 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import * as moment from 'moment-timezone'; // Realize that * is already imported from react import { useState } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Button, Col, Container, FormGroup, FormFeedback, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row, Table, PaginationItem, PaginationLink, Pagination} from 'reactstrap'; import TooltipHelpComponent from '../TooltipHelpComponent'; @@ -49,6 +49,7 @@ const PER_TABLE = 10; */ export default function EditConversionModalComponent(props: EditConversionModalComponentProps) { const translate = useTranslate(); + const intl = useIntl(); const [editConversion] = conversionsApi.useEditConversionMutation(); const [deleteConversion] = conversionsApi.useDeleteConversionMutation(); const [editSegment] = conversionSegmentsApi.useEditConversionSegmentMutation(); @@ -142,7 +143,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Validate datetime format const validFormat = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; if (!validFormat.test(e.target.value.trim())) { - setFieldErrors(prev => ({ ...prev, dateTimeError: `Invalid ${e.target.name} format. Use YYYY-MM-DD HH:mm:ss` })); + setFieldErrors(prev => ({ ...prev, + dateTimeError: intl.formatMessage( + { id: 'conversion.error.datetime.invalid' }, + {field: e.target.name} + ) + })); } else { setFieldErrors({}); } @@ -174,6 +180,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Splits a selected segment into two based on the chosen datetime and direction const handleSplitSegment = async () => { + // Ensure required state is set before proceeding if (!selectedSegment || !actionDirection || !actionDatetime) return; const splitTime = moment(actionDatetime); @@ -182,7 +189,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC if (!isStartTimeValid || !isEndTimeValid) { - setFieldErrors(prev => ({ ...prev, splitTimeError: 'Split time must be strictly between the segment start and end.' })); + setFieldErrors(prev => ({ ...prev, splitTimeError: translate('conversion.error.segment.splitTime')})); return; } @@ -247,14 +254,64 @@ export default function EditConversionModalComponent(props: EditConversionModalC } }; - // TODO: Verify deleteSegment range logic works correctly for both 'earlier' and 'later' actions - const handleDeleteSegment = async (segment: ConversionSegmentData) => { - await deleteSegment({ - sourceId: segment.sourceId, - destinationId: segment.destinationId, - startTime: segment.startTime, - endTime: segment.endTime - }); + // Deletes the selected segment and updates the adjacent segment's time range + // to preserve continuous coverage with no time gaps or overlaps + const handleDeleteSegment = async () => { + // Ensure required state is set before proceeding + if (!selectedSegment || !actionDirection) return; + try { + // Sort segment by start time + const sorted = [...segments].sort((a, b) => { + const segmentAStart = a.startTime === '-infinity' ? null : moment(a.startTime); + const segmentBStart = b.startTime === '-infinity' ? null : moment(b.startTime); + if (!segmentAStart) return -1; + if (!segmentBStart) return 1; + return segmentAStart.isBefore(segmentBStart) ? -1 : 1; + }); + // Find the index of the segment selected for deletion + const index = sorted.findIndex(seg => + seg.startTime === selectedSegment.startTime && seg.endTime === selectedSegment.endTime + ); + // Grab previous and next segments + const previous = sorted[index - 1]; + const next = sorted[index + 1]; + + // If deleting earlier, update the previous segment’s end time to match the selected segment’s end time + if (actionDirection === 'earlier' && previous) { + await editSegment({ + segment: { + ...previous, + endTime: selectedSegment.endTime + }, + originalStartTime: previous.startTime, + originalEndTime: previous.endTime + }); + } + // TODO: Adjust next segment to keep time continuity when deleting later + // If deleting later, update the next segment’s start time to match the selected segment’s start time + if (actionDirection === 'later' && next) { + await editSegment({ + segment: { + ...next, + startTime: selectedSegment.startTime + }, + originalStartTime: next.startTime, + originalEndTime: next.endTime + }); + } + await deleteSegment({ + sourceId: selectedSegment.sourceId, + destinationId: selectedSegment.destinationId, + startTime: selectedSegment.startTime, + endTime: selectedSegment.endTime + }); + setSelectedSegment(null); + setActionDirection(null); + // Refresh the segments list + await refetchSegments(); + } catch (error) { + console.error(error); + } }; /* End State */ @@ -445,10 +502,17 @@ export default function EditConversionModalComponent(props: EditConversionModalC const [showWarningModal, setShowWarningModal] = useState(false); const [warningMessage, setWarningMessage] = useState(''); - const handleWarningConfirm = () => { + const handleWarningConfirm = async () => { // Close the warning modal setShowWarningModal(false); + // If this warning is for a segment delete (earlier/later), + // perform the delete and exit early to avoid closing the Edit Conversion Modal + if (selectedSegment && actionDirection) { + await handleDeleteSegment(); + return; + } + // Proceed with saving changes // Close the modal first to avoid repeat clicks props.handleClose(); @@ -482,7 +546,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC // If the editing segment has no slope, no intercept, and no pattern applied, // show a warning modal to confirm saving a "neutral" segment with no effect. if (editingSegment && editingSegment.slope === 0 && editingSegment.intercept === 0 && editingSegment.weekPatternsId === null) { - setFieldErrors(prev => ({ ...prev, segmentValidationError: 'Segment has 0 slope and intercept and no pattern!' })); + setFieldErrors(prev => ({ ...prev, segmentValidationError: translate('conversion.error.segment.neutral')})); return; } // If editing a segment's time range, ensure there are no gaps or overlaps with adjacent segments, @@ -505,12 +569,22 @@ export default function EditConversionModalComponent(props: EditConversionModalC const next = sortedSegments[index + 1]; // Check for gaps/overlaps with previous segment if (previous && !moment(editingSegment.startTime).isSame(previous.endTime)) { - setFieldErrors(prev =>({ ...prev, startTimeError: `Start time must match end time of previous segment (${previous.endTime})` })); + setFieldErrors(prev =>({ ...prev, + startTimeError: intl.formatMessage( + { id: 'conversion.error.segment.startTimeMismatch' }, + { endTime: previous.endTime } + ) + })); return; } // Check for gaps/overlaps with next segment if (next && !moment(editingSegment.endTime).isSame(next.startTime)) { - setFieldErrors(prev =>({ ...prev, endTimeError: `End time must match start time of next segment (${next.startTime})` })); + setFieldErrors(prev =>({ ...prev, + endTimeError: intl.formatMessage( + { id: 'conversion.error.segment.endTimeMismatch' }, + { startTime: next.startTime } + ) + })); return; } } @@ -623,6 +697,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC value={editingSegment?.slope ?? ''} onChange={handleNumberChange} invalid={!!fieldErrors.segmentValidationError} + disabled={editingSegment?.weekPatternsId !== null} /> {fieldErrors.segmentValidationError} @@ -634,6 +709,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC value={editingSegment?.intercept ?? ''} onChange={handleNumberChange} invalid={!!fieldErrors.segmentValidationError} + disabled={editingSegment?.weekPatternsId !== null} /> {fieldErrors.segmentValidationError} @@ -695,7 +771,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC @@ -854,7 +930,8 @@ export default function EditConversionModalComponent(props: EditConversionModalC onClick={() => { setActionDirection('earlier'); setSelectedSegment(segment); - handleDeleteSegment(segment); + setWarningMessage('Are you sure you want to delete this segment and update the previous one?'); + setShowWarningModal(true); }} > @@ -864,7 +941,8 @@ export default function EditConversionModalComponent(props: EditConversionModalC onClick={() => { setActionDirection('later'); setSelectedSegment(segment); - handleDeleteSegment(segment); + setWarningMessage('Are you sure you want to delete this segment and update the next one?'); + setShowWarningModal(true); }} > diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index ca60cd7205..f5f7b0d7cd 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -84,6 +84,11 @@ const LocaleTranslationData = { "conversion.destination": "Destination:", "conversion.dropdown.displayable.option.admin": "Admin", "conversion.edit.conversion": "Details/Edit Conversion", + "conversion.error.segment.neutral": "Segment has 0 slope and intercept and no pattern!", + "conversion.error.segment.startTimeMismatch": "Start time must match end time of previous segment ({endTime})", + "conversion.error.segment.endTimeMismatch": "End time must match start time of next segment ({startTime})", + "conversion.error.segment.splitTime": "Split time must be strictly between the segment start and end.", + "conversion.error.datetime.invalid": "Invalid {field} format. Use 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.", @@ -712,6 +717,11 @@ const LocaleTranslationData = { "conversion.destination": "Destination:\u{26A1}", "conversion.dropdown.displayable.option.admin": "admin\u{26A1}", "conversion.edit.conversion": "Edit Conversion\u{26A1}", + "conversion.error.segment.neutral": "Le segment a une pente et une interception nulles et aucun modèle! ⚡", + "conversion.error.segment.startTimeMismatch": "L'heure de début doit correspondre à l'heure de fin du segment précédent ({endTime})\u{26A1}", + "conversion.error.segment.endTimeMismatch": "L'heure de fin doit correspondre à l'heure de début du segment suivant ({startTime})\u{26A1}", + "conversion.error.segment.splitTime": "L'heure de division doit être strictement comprise entre le début et la fin du segment.\u{26A1}", + "conversion.error.datetime.invalid": "Format invalide pour {field}. Utilisez YYYY-MM-DD HH:mm:ss\u{26A1}", "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}", @@ -1340,6 +1350,11 @@ const LocaleTranslationData = { "conversion.destination": "Destinación", "conversion.dropdown.displayable.option.admin": "Administrador", "conversion.edit.conversion": "Editar conversión", + "conversion.error.segment.neutral": "¡El segmento tiene pendiente e intersección en cero y ningún patrón! ⚡", + "conversion.error.segment.startTimeMismatch": "La hora de inicio debe coincidir con la hora de fin del segmento anterior ({endTime})\u{26A1}", + "conversion.error.segment.endTimeMismatch": "La hora de fin debe coincidir con la hora de inicio del siguiente segmento ({startTime})\u{26A1}", + "conversion.error.segment.splitTime": "La hora de división debe estar estrictamente entre el inicio y el fin del segmento.\u{26A1}", + "conversion.error.datetime.invalid": "Formato inválido para {field}. Use YYYY-MM-DD HH:mm:ss\u{26A1}", "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", From 9180c069523d6eeb2a3da255956cae85b48d9484 Mon Sep 17 00:00:00 2001 From: Gomez-V Date: Fri, 1 Aug 2025 14:08:44 -0700 Subject: [PATCH 034/170] feat: implement internationalization for day editing and segment splitting --- .../days/EditDailyPatternModalComponent.tsx | 35 ++++++++----------- .../days/EditDaySegmentModalComponent.tsx | 3 +- .../days/SplitDaySegmentComponent.tsx | 8 ++--- src/client/app/translations/data.ts | 10 ++++++ 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/client/app/components/days/EditDailyPatternModalComponent.tsx b/src/client/app/components/days/EditDailyPatternModalComponent.tsx index b127788254..4acd703c36 100644 --- a/src/client/app/components/days/EditDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/EditDailyPatternModalComponent.tsx @@ -91,12 +91,12 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo editDayMutation(dayDetails).unwrap() .then(() => { - // TODO: internationalize - showSuccessNotification('Successfully edited day.'); + + showSuccessNotification(translate('daily.patterns.edit.success')); props.handleClose(); }).catch(error => { - // TODO: internationalize - showErrorNotification(error); + + showErrorNotification(translate('daily.patterns.edit.error')); }); }; @@ -156,12 +156,11 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo setShowDeleteModal(false); deleteDayMutation({ id: dayDetails.id }).unwrap() .then(() => { - // TODO: internationalize - showSuccessNotification('Successfully deleted day.'); + showSuccessNotification(translate('daily.patterns.delete.success')); props.handleClose(); }) .catch(error => { - showErrorNotification(error); + showErrorNotification(translate('daily.patterns.delete.error')); }); }; @@ -196,16 +195,15 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo {/* Table */} {/* TODO: internationalize */} -
Segments Table
+
- {/* TODO: internationalize */} - - - - - + + + + + @@ -282,8 +280,7 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo {/* Delete day */} {/* On click calls the function handleSaveChanges in this component */} - diff --git a/src/client/app/components/days/DayDetailComponent.tsx b/src/client/app/components/days/DayDetailComponent.tsx index 7a0014b7c3..327952b2e8 100644 --- a/src/client/app/components/days/DayDetailComponent.tsx +++ b/src/client/app/components/days/DayDetailComponent.tsx @@ -8,7 +8,7 @@ import SpinnerComponent from '../SpinnerComponent'; import TooltipHelpComponent from '../TooltipHelpComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import DayViewComponent from './DayViewComponent'; -import CreateDailyPatternModalComponent from './CreateDailyPatternModalComponent'; +import CreateDayModalComponent from './CreateDayModalComponent'; import { daysApi, stableEmptyDays } from '../../redux/api/daysApi'; /** @@ -20,11 +20,11 @@ export default function DayDetailComponent() { const locale = useAppSelector(selectSelectedLanguage); // Day state - const { data: dayState = stableEmptyDays, isFetching: dayFetching } = daysApi.useGetDailyPatternsQuery(); + const { data: dayState = stableEmptyDays, isFetching: dayFetching } = daysApi.useGetDaysQuery(); const tooltipStyle = { ...tooltipBaseStyle, - tooltipDayView: 'help.admin.dailypatternview' + tooltipDayView: 'help.admin.dayview' }; return ( @@ -36,7 +36,7 @@ export default function DayDetailComponent() { ) : (
- +

@@ -47,11 +47,11 @@ export default function DayDetailComponent() {

{/* Placeholder for create day modal */} - +
{Object.values(dayState) - .sort((a: Day, b: Day) => (a.dayName || '').toLowerCase().localeCompare((b.dayName || '').toLowerCase(), locale, { sensitivity: 'accent' })) + .sort((a: Day, b: Day) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase(), locale, { sensitivity: 'accent' })) .map(day => ( ))} diff --git a/src/client/app/components/days/DayViewComponent.tsx b/src/client/app/components/days/DayViewComponent.tsx index 36f1c0d8f1..f5bd35abd8 100644 --- a/src/client/app/components/days/DayViewComponent.tsx +++ b/src/client/app/components/days/DayViewComponent.tsx @@ -38,14 +38,13 @@ export default function DayViewComponent(props: DayViewComponentProps) { // Create header from sourceId, destinationId identifiers //const conversionIdentifier = String(unitDataById[props.conversion.sourceId]?.identifier + conversionArrow(props.conversion.bidirectional) + // unitDataById[props.conversion.destinationId]?.identifier); - const dayIdentifier = props.day.dayName || 'Unnamed Day'; // Unlike the details component, we don't check if units are loaded since must come through that page. return (
- {dayIdentifier} + {props.day.name}
{/* Only show first 30 characters so card does not get too big. Should limit to one line */} diff --git a/src/client/app/components/weeks/CreateWeekModalComponent.tsx b/src/client/app/components/weeks/CreateWeekModalComponent.tsx index 71566ac196..5b11367c73 100644 --- a/src/client/app/components/weeks/CreateWeekModalComponent.tsx +++ b/src/client/app/components/weeks/CreateWeekModalComponent.tsx @@ -26,14 +26,14 @@ export default function CreateWeekModalComponent(): React.ReactElement { const [showModal, setShowModal] = React.useState(false); // Fetch days data - const { data: days, isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); + 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.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); }, [days]); // Fetch weeks data (used to check if week name already exists) @@ -197,7 +197,7 @@ export default function CreateWeekModalComponent(): React.ReactElement { {sortedDays?.map(day => ( ))} diff --git a/src/client/app/components/weeks/EditWeekModalComponent.tsx b/src/client/app/components/weeks/EditWeekModalComponent.tsx index b5df59bae9..4c5d5c4a44 100644 --- a/src/client/app/components/weeks/EditWeekModalComponent.tsx +++ b/src/client/app/components/weeks/EditWeekModalComponent.tsx @@ -45,14 +45,14 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp const [weekDetails, setWeekDetails] = React.useState({ ...props.week }); // Fetch days data - const { data: days, isFetching: isFetchingDays } = daysApi.useGetDailyPatternsQuery(); + 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.dayName.toLocaleLowerCase().localeCompare(b.dayName.toLocaleLowerCase())); + return [...days].sort((a, b) => a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase())); }, [days]); // Fetch weeks data (used to check if week name already exists) @@ -201,7 +201,7 @@ export default function EditWeekModalComponent(props: EditWeekModalComponentProp > {sortedDays?.map(day => ( ))} diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index c8dd7b9e64..f7cf276d33 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -33,8 +33,8 @@ export const baseApi = createApi({ 'Preferences', 'Users', 'ConversionDetails', - 'DailyPattern', - 'DailyPatternSegment', + 'Days', + 'DaySegments', 'Units', 'Cik', 'Readings', diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts index 3f0e50040d..605c85567b 100644 --- a/src/client/app/redux/api/daySegmentsApi.ts +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -4,46 +4,46 @@ import { DaySegment } from '../../types/redux/days'; // Adjust path as needed export const daySegmentsApi = baseApi.injectEndpoints({ endpoints: builder => ({ - getDailyPatternSegments: builder.query({ + getDaySegmentss: builder.query({ query: () => 'api/daySegments', - providesTags: ['DailyPatternSegment'] + providesTags: ['DaySegments'] }), - getDailyPatternSegmentById: builder.query({ + getDaySegmentsById: builder.query({ query: id => `api/daySegments/${id}`, - providesTags: (result, error, id) => [{ type: 'DailyPatternSegment', id }] + providesTags: (result, error, id) => [{ type: 'DaySegments', id }] }), - getDailyPatternSegmentsByDayId: builder.query({ + getDaySegmentssByDayId: builder.query({ query: dayId => `api/daySegments/dayId/${dayId}`, - providesTags: (result, error, dayId) => [{ type: 'DailyPatternSegment', dayId }] + providesTags: (result, error, dayId) => [{ type: 'DaySegments', dayId }] }), - addDailyPatternSegment: builder.mutation>({ + addDaySegments: builder.mutation>({ query: daySegment => ({ url: 'api/daySegments/add', method: 'POST', body: daySegment }), - invalidatesTags: ['DailyPatternSegment'] + invalidatesTags: ['DaySegments'] }), - editDailyPatternSegment: builder.mutation({ + editDaySegments: builder.mutation({ query: daySegment => ({ url: 'api/daySegments/edit', method: 'POST', body: daySegment }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', id: arg.id }] }), - deleteDailyPatternSegment: builder.mutation({ + deleteDaySegments: builder.mutation({ query: ({ id }) => ({ url: 'api/daySegments/delete', method: 'POST', body: { id } }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] + invalidatesTags: (result, error, arg) => [{ type: 'DaySegments', id: arg.id }] }) }) }); -export const selectDaySegmentsQueryState = daySegmentsApi.endpoints.getDailyPatternSegments.select(); +export const selectDaySegmentsQueryState = daySegmentsApi.endpoints.getDaySegmentss.select(); export const selectDaySegments = createSelector( selectDaySegmentsQueryState, ({ data: daySegments = [] }) => daySegments @@ -52,10 +52,10 @@ export const selectDaySegments = createSelector( export const stableEmptyDaySegments: DaySegment[] = []; export const { - useGetDailyPatternSegmentsQuery, - useGetDailyPatternSegmentByIdQuery, - useGetDailyPatternSegmentsByDayIdQuery, - useAddDailyPatternSegmentMutation, - useEditDailyPatternSegmentMutation, - useDeleteDailyPatternSegmentMutation + useGetDaySegmentssQuery, + useGetDaySegmentsByIdQuery, + useGetDaySegmentssByDayIdQuery, + useAddDaySegmentsMutation, + useEditDaySegmentsMutation, + useDeleteDaySegmentsMutation } = daySegmentsApi; \ No newline at end of file diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 12c587c411..e0b5f5b470 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -5,42 +5,42 @@ import { baseApi } from './baseApi'; // Tag type for cache invalidation export const daysApi = baseApi.injectEndpoints({ endpoints: builder => ({ - getDailyPatterns: builder.query({ + getDays: builder.query({ query: () => 'api/days', - providesTags: ['DailyPattern'] + providesTags: ['Days'] }), - getDailyPatternById: builder.query({ + getDayById: builder.query({ query: id => `api/days/${id}`, - providesTags: (result, error, id) => [{ type: 'DailyPattern', id }] + providesTags: (result, error, id) => [{ type: 'Days', id }] }), - addDailyPattern: builder.mutation({ - query: dailyPattern => ({ + addDay: builder.mutation({ + query: Day => ({ url: 'api/days/add', method: 'POST', - body: dailyPattern + body: Day }), - invalidatesTags: ['DailyPattern'] + invalidatesTags: ['Days'] }), - editDailyPattern: builder.mutation({ - query: dailyPattern => ({ + editDay: builder.mutation({ + query: Day => ({ url: 'api/days/edit', method: 'POST', - body: dailyPattern + body: Day }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + invalidatesTags: (result, error, arg) => [{ type: 'Days', id: arg.id }] }), - deleteDailyPattern: builder.mutation({ + deleteDay: builder.mutation({ query: ({ id }) => ({ url: 'api/days/delete', method: 'POST', body: { id } }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + invalidatesTags: (result, error, arg) => [{ type: 'Days', id: arg.id }] }) }) }); -export const selectDaysQueryState = daysApi.endpoints.getDailyPatterns.select(); +export const selectDaysQueryState = daysApi.endpoints.getDays.select(); export const selectAllDays = createSelector( selectDaysQueryState, ({ data: days = [] }) => days @@ -49,9 +49,9 @@ export const selectAllDays = createSelector( export const stableEmptyDays: Day[] = []; export const { - useGetDailyPatternsQuery, - useGetDailyPatternByIdQuery, - useAddDailyPatternMutation, - useEditDailyPatternMutation, - useDeleteDailyPatternMutation + useGetDaysQuery, + useGetDayByIdQuery, + useAddDayMutation, + useEditDayMutation, + useDeleteDayMutation } = daysApi; \ No newline at end of file diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index c56f20c627..a90222fcec 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -342,12 +342,12 @@ export const selectDefaultCreateConversionValues = createAppSelector( } ); -export const selectDefaultCreateDailyPatternValues = createAppSelector( +export const selectDefaultCreateDayValues = createAppSelector( [selectAllUnits], () => { const defaultValues = { - dayName: '', - dailyPatternNote: '', + name: '', + DayNote: '', slope: 0, intercept: 0, startHour:0, @@ -430,22 +430,22 @@ export const isValidCreateMeter = createAppSelector( * - Ensures the day name does not already exist (case-insensitive) in the list of days. * Returns a tuple: [isValid, message]. * @param _state The Redux state (unused in this selector). - * @param dailyPattern The daily pattern object containing the day name. - * @param dailyPattern.dayName The name of the day to validate for uniqueness and non-blank value. + * @param Day The daily pattern object containing the day name. + * @param Day.name The name of the day to validate for uniqueness and non-blank value. * @returns A tuple where the first element is a boolean indicating validity, and the second is a message string. */ -export const selectIsValidCreateDailyPattern = createAppSelector( +export const selectIsValidCreateDay = createAppSelector( [ selectAllDays, - (_state, dailyPattern: { dayName: string }) => dailyPattern + (_state, Day: { name: string }) => Day ], - (days, dailyPattern): [boolean, string] => { - if (!dailyPattern.dayName || dailyPattern.dayName.trim() === '') { + (days, Day): [boolean, string] => { + if (!Day.name || Day.name.trim() === '') { return [false, translate('daily.patterns.create.name.required')]; } - // Check if dayName already exists (case-insensitive) + // Check if name already exists (case-insensitive) const exists = days.some(day => - day.dayName?.toLowerCase() === dailyPattern.dayName.trim().toLowerCase() + day.name?.toLowerCase() === Day.name.trim().toLowerCase() ); if (exists) { return [false, translate('daily.patterns.create.name.exists')]; diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 447e67a1ce..b1e5973407 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -265,7 +265,7 @@ 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.dailypatternview": "This page shows information on daily patterns. Please visit {link} for further details and information.", + "help.admin.dayview": "This page shows information on daily patterns. 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.", @@ -874,7 +874,7 @@ 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.dailypatternview": "This page shows information on daily patterns. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayview": "This page shows information on daily patterns. 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}", @@ -1483,7 +1483,7 @@ 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.dailypatternview": "This page shows information on daily patterns. Please visit {link} for further details and information.\u{26A1}", + "help.admin.dayview": "This page shows information on daily patterns. 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.", diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index 39b10738ce..47832ddecc 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,6 +1,6 @@ export interface Day { id: number; - dayName: string; + name: string; note: string; segments: DaySegment[]; } From 065abeb0794775110a18bbc5585667c6b5764af0 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Fri, 1 Aug 2025 21:41:26 -0700 Subject: [PATCH 036/170] feat(days): ability to edit day segment hours --- .../days/EditDailyPatternModalComponent.tsx | 57 ++++--- .../days/EditDaySegmentModalComponent.tsx | 153 +++++++++++++++--- .../days/SplitDaySegmentComponent.tsx | 48 ++++-- src/client/app/redux/api/daySegmentsApi.ts | 7 +- src/client/app/redux/api/daysApi.ts | 10 +- src/client/app/types/redux/days.ts | 27 +--- 6 files changed, 219 insertions(+), 83 deletions(-) diff --git a/src/client/app/components/days/EditDailyPatternModalComponent.tsx b/src/client/app/components/days/EditDailyPatternModalComponent.tsx index b127788254..976a8be5b3 100644 --- a/src/client/app/components/days/EditDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/EditDailyPatternModalComponent.tsx @@ -15,7 +15,7 @@ import { Row, Table } from 'reactstrap'; -import { Day, DaySegment } from 'types/redux/days'; +import { Day, DaySegment, UpdateDaySegmentPayload } from 'types/redux/days'; import { daysApi } from '../../redux/api/daysApi'; import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; import { LocaleDataKey } from '../../translations/data'; @@ -65,15 +65,7 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo const [dayDetails, setDayDetails] = React.useState({ ...props.day }); - const { data: daySegments } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(props.day.id); - - // Sort day segments by start hour to display them in the correct order in the table - const sortedDaySegments = React.useMemo(() => { - if (!daySegments) { - return []; - } - return [...daySegments].sort((a, b) => a.startHour - b.startHour); - }, [daySegments]); + const { data: daySegments = [] } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(props.day.id); // Fetch days data (used to check if day name already exists) const { data: days } = daysApi.useGetDailyPatternsQuery(); @@ -102,8 +94,8 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo // Pagination const [page, setPage] = React.useState(1); - const totalPages = Math.ceil(sortedDaySegments?.length ?? 1 / PER_PAGE); - const paged = sortedDaySegments?.slice((page - 1) * PER_PAGE, page * PER_PAGE); + const totalPages = Math.ceil(daySegments.length ?? 1 / 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 @@ -124,6 +116,20 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo return true; }, [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); + }; const [editDaySegmentMutation] = daySegmentsApi.useEditDailyPatternSegmentMutation(); const [deleteDaySegmentMutation] = daySegmentsApi.useDeleteDailyPatternSegmentMutation(); @@ -131,14 +137,20 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo // Function to handle deleting a segment // Adjusts the neighboring segment's start or end hour accordingly const handleDeleteSegment = (seg: DaySegment, direction: 'earlier' | 'later') => { - const index = sortedDaySegments.findIndex(s => s.id === seg.id); - let targetSegment = direction === 'earlier' ? sortedDaySegments[index - 1] : sortedDaySegments[index + 1]; - targetSegment = { + const index = daySegments.findIndex(s => s.id === seg.id); + const targetSegment = direction === 'earlier' ? daySegments[index - 1] : daySegments[index + 1]; + + const startHour = direction === 'later' ? seg.startHour : targetSegment.startHour; + const endHour = direction === 'earlier' ? seg.endHour : targetSegment.endHour; + + const editSegment: UpdateDaySegmentPayload = { ...targetSegment, - startHour: direction === 'later' ? seg.startHour : targetSegment.startHour, - endHour: direction === 'earlier' ? seg.endHour : targetSegment.endHour + startHour, + endHour, + originalStartHour: startHour, + originalEndHour: endHour }; - const editPromise = editDaySegmentMutation(targetSegment).unwrap(); + const editPromise = editDaySegmentMutation(editSegment).unwrap(); const deletePromise = deleteDaySegmentMutation({ id: seg.id }).unwrap(); Promise.all([editPromise, deletePromise]) @@ -224,7 +236,9 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo : seg.note ?? ''}
Time RangeSlopeInterceptNoteEdit Del ↑ - + {/* only show split buttons if segment is longer than 1 hour */} @@ -255,7 +269,7 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo
{/* Pagination */} - {sortedDaySegments && sortedDaySegments.length > PER_PAGE && ( + {daySegments && daySegments.length > PER_PAGE && ( setPage(1)} /> @@ -305,6 +319,9 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo actionRejectText={translate('cancel')} />
+ + {/* Edit Day Segment modal */} + {editSegment && } ); } diff --git a/src/client/app/components/days/EditDaySegmentModalComponent.tsx b/src/client/app/components/days/EditDaySegmentModalComponent.tsx index 26570422ea..0909b05004 100644 --- a/src/client/app/components/days/EditDaySegmentModalComponent.tsx +++ b/src/client/app/components/days/EditDaySegmentModalComponent.tsx @@ -4,9 +4,10 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Button, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { Button, Col, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row } from 'reactstrap'; import { daySegmentsApi } from '../../redux/api/daySegmentsApi'; -import { DaySegment } from '../../types/redux/days'; +import { DaySegment, UpdateDaySegmentPayload } from '../../types/redux/days'; +import { showErrorNotification } from '../../utils/notifications'; /** @@ -25,9 +26,19 @@ function hourToTime(hour: number) { interface EditDaySegmentModalComponentProps { /** - * The week to edit + * 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; } /** @@ -37,9 +48,12 @@ interface EditDaySegmentModalComponentProps { * @returns Day segment edit element */ export default function EditDaySegmentModalComponent(props: EditDaySegmentModalComponentProps): React.ReactElement { - const [daySegment, setDaySegment] = React.useState({ ...props.daySegment }); + const [daySegment, setDaySegment] = React.useState( + { ...props.daySegment, originalStartHour: props.daySegment.startHour, originalEndHour: props.daySegment.endHour } + ); - const [showEditModal, setShowEditModal] = React.useState(false); + // Fetch day segments by day ID to validate start and end hours + const { data: daySegments = [] } = daySegmentsApi.useGetDailyPatternSegmentsByDayIdQuery(props.daySegment.dayId); const [editDaySegmentMutation, { isLoading: isSaving }] = daySegmentsApi.useEditDailyPatternSegmentMutation(); @@ -52,33 +66,69 @@ export default function EditDaySegmentModalComponent(props: EditDaySegmentModalC }; const handleSubmit = () => { + if (!isSegmentValid) { + return; + } editDaySegmentMutation(daySegment).unwrap() .then(() => { - handleHideEditModal(); + props.handleClose(); + }) + .catch(error => { + showErrorNotification(error); }); }; - const handleShowEditModal = () => setShowEditModal(true); - const handleHideEditModal = () => setShowEditModal(false); + // Validate the start hour + // It should be a valid hour and not conflict with existing segments + const isStartHourValid = React.useMemo(() => { + if (daySegment.startHour < 0 || daySegment.startHour >= daySegment.endHour || daySegment.startHour >= 24 || daySegment.startHour < 0) { + return false; + } + // Check if the start hour does not conflict with existing segments + const segmentIndex = daySegments.findIndex(s => s.id === daySegment.id); + if (segmentIndex > 0) { + const earlierSegment = daySegments[segmentIndex - 1]; + return daySegment.startHour > earlierSegment.startHour; + } + + return true; + }, [daySegment.startHour, daySegment.endHour]); + + // Validate the end hour + // It should be a valid hour and not conflict with existing segments + const isEndHourValid = React.useMemo(() => { + if (daySegment.endHour <= daySegment.startHour || daySegment.endHour > 24 || daySegment.endHour < 0) { + return false; + } + // Check if the end hour does not conflict with existing segments + const segmentIndex = daySegments.findIndex(s => s.id === daySegment.id); + if (segmentIndex < daySegments.length - 1) { + const laterSegment = daySegments[segmentIndex + 1]; + return daySegment.endHour < laterSegment.endHour; + } + return true; + }, [daySegment.startHour, daySegment.endHour]); + + // 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]); + return ( <> - - - - + + {/* TODO: internationalize */} Edit Segment - {hourToTime(daySegment.startHour)} - {hourToTime(daySegment.endHour)} - - + {/* TODO: internationalize */} + - + {/* TODO: internationalize */} + - + + + + + + {hourToTime(daySegment.startHour)} + + + + + + + + + + {hourToTime(daySegment.endHour)} + + + + + + + + + {/* Discard changes */} - {/* Save changes */} diff --git a/src/client/app/components/days/SplitDaySegmentComponent.tsx b/src/client/app/components/days/SplitDaySegmentComponent.tsx index 650e1f5b52..49341d87c0 100644 --- a/src/client/app/components/days/SplitDaySegmentComponent.tsx +++ b/src/client/app/components/days/SplitDaySegmentComponent.tsx @@ -32,19 +32,21 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent const [splitHour, setSplitHour] = React.useState(props.daySegment.startHour + 1); - const [showEditModal, setShowEditModal] = React.useState(false); + const [showSplitModal, setShowSplitModal] = React.useState(false); - const [editDaySegmentMutation, { isLoading: isEditSaving }] = daySegmentsApi.useEditDailyPatternSegmentMutation(); + const [deleteDaySegmentMutation, { isLoading: isDeleting }] = daySegmentsApi.useDeleteDailyPatternSegmentMutation(); const [addDaySegmentMutation, { isLoading: isAddSaving }] = daySegmentsApi.useAddDailyPatternSegmentMutation(); - const handleShowEditModal = () => setShowEditModal(true); - const handleHideEditModal = () => setShowEditModal(false); + const handleShowSplitModal = () => setShowSplitModal(true); + const handleHideSplitModal = () => setShowSplitModal(false); const handleSplitInputChange = (e: React.ChangeEvent) => { setSplitHour(Number(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; @@ -53,16 +55,26 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent }, [splitHour, props.daySegment, props.direction]); - const handleSplitSegment = () => { + // Handle the split operation + // It deletes the original segment and creates two new segments based on the split hour + const handleSubmit = () => { if (!isSplitValid) { return; } - const editSegment: DaySegment = { - ...props.daySegment, + // New segment based on the original segment + // It copies the slope and intercept from the original segment + // and sets the start and end hours based on the split hour + // The original segment is deleted after the new segments are created + const copySegment: Omit = { + dayId: props.daySegment.dayId, + slope: props.daySegment.slope, + intercept: props.daySegment.intercept, + note: props.daySegment.note, startHour: props.direction === 'earlier' ? splitHour : props.daySegment.startHour, endHour: props.direction === 'earlier' ? props.daySegment.endHour : splitHour }; + const newSegment: Omit = { dayId: props.daySegment.dayId, slope: 0, @@ -71,12 +83,16 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent endHour: props.direction === 'earlier' ? splitHour : props.daySegment.endHour }; - const editPromise = editDaySegmentMutation(editSegment).unwrap(); - const addPromise = addDaySegmentMutation(newSegment).unwrap(); + const deleteOriginalSegment = deleteDaySegmentMutation({ id: props.daySegment.id }).unwrap(); + + const createCopySegment = addDaySegmentMutation(copySegment).unwrap(); + const createNewSegment = addDaySegmentMutation(newSegment).unwrap(); - Promise.all([editPromise, addPromise]) + deleteOriginalSegment + .then(() => + Promise.all([createCopySegment, createNewSegment])) .then(() => { - handleHideEditModal(); + handleHideSplitModal(); }) .catch(error => { showErrorNotification(error); @@ -85,11 +101,11 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent return ( <> - - + @@ -112,13 +128,13 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent - diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts index 0ca6d92e67..45627ee499 100644 --- a/src/client/app/redux/api/daySegmentsApi.ts +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createSelector } from '@reduxjs/toolkit'; -import { DaySegment } from '../../types/redux/days'; +import { DaySegment, UpdateDaySegmentPayload } from '../../types/redux/days'; import { baseApi } from './baseApi'; // TODO: fix tags and invalidation @@ -31,14 +31,16 @@ export const daySegmentsApi = baseApi.injectEndpoints({ method: 'POST', body: daySegment }), + transformErrorResponse: res => res.data, invalidatesTags: ['DailyPatternSegment'] }), - editDailyPatternSegment: builder.mutation({ + editDailyPatternSegment: builder.mutation({ query: daySegment => ({ url: 'api/daySegments/edit', method: 'POST', body: daySegment }), + transformErrorResponse: res => res.data, invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', dayId: arg.dayId }] }), deleteDailyPatternSegment: builder.mutation({ @@ -47,6 +49,7 @@ export const daySegmentsApi = baseApi.injectEndpoints({ method: 'POST', body: { id } }), + transformErrorResponse: res => res.data, invalidatesTags: (result, error, arg) => [{ type: 'DailyPatternSegment', id: arg.id }] }) }) diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index 4ed9c031bd..e4c509ea21 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -1,3 +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/. */ + import { createSelector } from '@reduxjs/toolkit'; import { Day } from '../../types/redux/days'; import { baseApi } from './baseApi'; @@ -19,6 +23,7 @@ export const daysApi = baseApi.injectEndpoints({ method: 'POST', body: dailyPattern }), + transformErrorResponse: res => res.data, invalidatesTags: ['DailyPattern'] }), editDailyPattern: builder.mutation({ @@ -28,7 +33,7 @@ export const daysApi = baseApi.injectEndpoints({ body: dailyPattern }), transformErrorResponse: res => res.data, - invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + invalidatesTags: ['DailyPattern'] }), deleteDailyPattern: builder.mutation({ query: ({ id }) => ({ @@ -36,7 +41,8 @@ export const daysApi = baseApi.injectEndpoints({ method: 'POST', body: { id } }), - invalidatesTags: (result, error, arg) => [{ type: 'DailyPattern', id: arg.id }] + transformErrorResponse: res => res.data, + invalidatesTags: ['DailyPattern'] }) }) }); diff --git a/src/client/app/types/redux/days.ts b/src/client/app/types/redux/days.ts index f624743506..e1c30faf8e 100644 --- a/src/client/app/types/redux/days.ts +++ b/src/client/app/types/redux/days.ts @@ -1,3 +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/. */ + export interface Day { id: number; name: string; @@ -15,22 +19,7 @@ export interface DaySegment { note?: string; } -// export interface CreateDaySegmentPayload { -// dayId: string; -// hour: number; -// slope: number; -// intercept: number; -// note?: string; -// } - -// export interface UpdateDaySegmentPayload { -// id: string; -// hour: number; -// slope: number; -// intercept: number; -// note?: string; -// } - -// export interface DeleteDaySegmentPayload { -// id: string; -// } +export interface UpdateDaySegmentPayload extends DaySegment { + originalStartHour: number; + originalEndHour: number; +} From 5e667fba6336659c34853d688e991a9834287892 Mon Sep 17 00:00:00 2001 From: Evan Carey Date: Fri, 1 Aug 2025 22:01:14 -0700 Subject: [PATCH 037/170] update endpoints to match server; fix bad file path on server --- .../app/components/days/EditDailyPatternModalComponent.tsx | 2 -- src/client/app/components/days/SplitDaySegmentComponent.tsx | 4 ++-- src/client/app/redux/api/daySegmentsApi.ts | 4 ++-- src/client/app/redux/api/daysApi.ts | 2 +- src/client/app/redux/api/weeksApi.ts | 2 +- src/server/models/DaySegment.js | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/client/app/components/days/EditDailyPatternModalComponent.tsx b/src/client/app/components/days/EditDailyPatternModalComponent.tsx index 2760a9e59c..cb54f2a0ee 100644 --- a/src/client/app/components/days/EditDailyPatternModalComponent.tsx +++ b/src/client/app/components/days/EditDailyPatternModalComponent.tsx @@ -83,11 +83,9 @@ export default function EditDailyPatternModalComponent(props: EditDailyPatternMo editDayMutation(dayDetails).unwrap() .then(() => { - showSuccessNotification(translate('daily.patterns.edit.success')); props.handleClose(); }).catch(error => { - showErrorNotification(translate('daily.patterns.edit.error')); }); }; diff --git a/src/client/app/components/days/SplitDaySegmentComponent.tsx b/src/client/app/components/days/SplitDaySegmentComponent.tsx index 8f59e29b2f..87c57400f2 100644 --- a/src/client/app/components/days/SplitDaySegmentComponent.tsx +++ b/src/client/app/components/days/SplitDaySegmentComponent.tsx @@ -123,8 +123,8 @@ export default function SplitDaySegmentComponent(props: SplitDaySegmentComponent /> {!isSplitValid && translate('split.hour.invalid') - .replace('{start}', String(props.daySegment.startHour + 1)) - .replace('{end}', String(props.daySegment.endHour - 1))} + .replace('{start}', String(props.daySegment.startHour + 1)) + .replace('{end}', String(props.daySegment.endHour - 1))} diff --git a/src/client/app/redux/api/daySegmentsApi.ts b/src/client/app/redux/api/daySegmentsApi.ts index 45627ee499..c8fcc14835 100644 --- a/src/client/app/redux/api/daySegmentsApi.ts +++ b/src/client/app/redux/api/daySegmentsApi.ts @@ -19,7 +19,7 @@ export const daySegmentsApi = baseApi.injectEndpoints({ }), getDailyPatternSegmentsByDayId: builder.query({ query: dayId => ({ - url: 'api/daySegments/segments', + url: 'api/daySegments/dayId', method: 'POST', body: { dayId } }), @@ -27,7 +27,7 @@ export const daySegmentsApi = baseApi.injectEndpoints({ }), addDailyPatternSegment: builder.mutation>({ query: daySegment => ({ - url: 'api/daySegments/add', + url: 'api/daySegments/addDaySegment', method: 'POST', body: daySegment }), diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index e4c509ea21..9e342b00d5 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -19,7 +19,7 @@ export const daysApi = baseApi.injectEndpoints({ }), addDailyPattern: builder.mutation({ query: dailyPattern => ({ - url: 'api/days/add', + url: 'api/days/addDay', method: 'POST', body: dailyPattern }), diff --git a/src/client/app/redux/api/weeksApi.ts b/src/client/app/redux/api/weeksApi.ts index 20a9416e99..26cf21d4f3 100644 --- a/src/client/app/redux/api/weeksApi.ts +++ b/src/client/app/redux/api/weeksApi.ts @@ -25,7 +25,7 @@ export const weeksApi = baseApi.injectEndpoints({ addWeek: builder.mutation>({ query: week => ({ - url: 'api/weeks/add', + url: 'api/weeks/addWeek', method: 'POST', body: week }), diff --git a/src/server/models/DaySegment.js b/src/server/models/DaySegment.js index 2593f5a330..4651c2cd7f 100644 --- a/src/server/models/DaySegment.js +++ b/src/server/models/DaySegment.js @@ -81,7 +81,7 @@ class DaySegment { * @returns {Promise.} */ static async getByDayId(dayId, conn) { - const rows = await conn.any(sqlFile('daySegment/get_by_day_id.sql'), { + const rows = await conn.any(sqlFile('daySegment/get_by_dayId.sql'), { dayId: dayId }); return rows.map(DaySegment.mapRow) From 692be81fb014cc9d99ab13df811ba9c5cdb36c8e Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Fri, 1 Aug 2025 23:04:46 -0600 Subject: [PATCH 038/170] changed all daily patterns to days --- .../app/components/HeaderButtonsComponent.tsx | 2 +- .../app/components/TooltipHelpComponent.tsx | 3 + .../days/CreateDayModalComponent.tsx | 16 ++-- .../components/days/DayDetailComponent.tsx | 4 +- .../app/components/days/DayViewComponent.tsx | 2 +- .../app/redux/selectors/adminSelectors.ts | 4 +- src/client/app/translations/data.ts | 91 ++++++++++--------- 7 files changed, 65 insertions(+), 57 deletions(-) diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index fd6deb3433..d3d008aac5 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -233,7 +233,7 @@ export default function HeaderButtonsComponent() { disabled={state.shouldDaysButtonDisabled} tag={Link} to="/days"> - + { // Show warning modal if slope and intercept are both 0 if (patternState.initialPattern.slope === 0 && patternState.initialPattern.intercept === 0) { - setWarningMessage(translate('daily.patterns.slope.intercept.zero')); + setWarningMessage(translate('day.slope.intercept.zero')); setShowWarningModal(true); } else { // Close modal first to avoid repeat clicks @@ -180,7 +180,7 @@ export default function CreateDayModalComponent() { const tooltipStyle = { ...tooltipBaseStyle, - tooltipCreateConversionView: 'help.admin.conversioncreate' + tooltipCreateConversionView: 'help.admin.daycreate' }; return ( @@ -195,15 +195,15 @@ export default function CreateDayModalComponent() { /> {/* Show modal button */} - - + +
- +
{/* when any of the conversion are changed call one of the functions. */} @@ -269,7 +269,7 @@ export default function CreateDayModalComponent() { {/* Start hour input*/} - + {/* End hour input*/} - +

- +
- +

diff --git a/src/client/app/components/days/DayViewComponent.tsx b/src/client/app/components/days/DayViewComponent.tsx index f5bd35abd8..511e43951b 100644 --- a/src/client/app/components/days/DayViewComponent.tsx +++ b/src/client/app/components/days/DayViewComponent.tsx @@ -52,7 +52,7 @@ export default function DayViewComponent(props: DayViewComponentProps) {
{/* Creates a child DayModalEditComponent */} {/* { if (!Day.name || Day.name.trim() === '') { - return [false, translate('daily.patterns.create.name.required')]; + return [false, translate('day.create.name.required')]; } // Check if name already exists (case-insensitive) const exists = days.some(day => day.name?.toLowerCase() === Day.name.trim().toLowerCase() ); if (exists) { - return [false, translate('daily.patterns.create.name.exists')]; + return [false, translate('day.create.name.exists')]; } return [true, 'Daily Pattern is Valid']; } diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index b1e5973407..d194ba2792 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -136,24 +136,23 @@ const LocaleTranslationData = { "csvReadings": "CSV Readings", "custom.date.range": "Custom date range", "custom.value": "Custom value", - "daily.patterns": "Patterns - Daily", - "daily.patterns.create": "Create a Day", - "daily.patterns.create.name.required": "Day name is required", - "daily.patterns.create.name.exists": "Day already exists", - "daily.patterns.edit": "Details/Edit Day", - "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?", - "daily.patterns.start.hour": "Start Hour", - "daily.patterns.end.hour": "End Hour", "date.range": 'Date Range', "day": "Day", + "day.create": "Create a Day", + "day.create.name.exists": "Day already exists", + "day.create.name.required": "Day name is required", + "day.edit": "Details/Edit Day", + "day.end.hour": "End Hour", + "day.friday": "Friday", + "day.monday": "Monday", "day.of.week": "Day of Week", + "day.saturday": "Saturday", + "day.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?", + "day.start.hour": "Start Hour", "day.sunday": "Sunday", - "day.monday": "Monday", + "day.thursday": "Thursday", "day.tuesday": "Tuesday", "day.wednesday": "Wednesday", - "day.thursday": "Thursday", - "day.friday": "Friday", - "day.saturday": "Saturday", "days": "Days", "days.enter": "Enter in days and then hit enter", "decreasing": "decreasing", @@ -265,7 +264,9 @@ 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.dayview": "This page shows information on daily patterns. 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.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.", @@ -456,6 +457,7 @@ 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", @@ -745,24 +747,22 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Custom value\u{26A1}", - "daily.patterns": "Patterns - Daily\u{26A1}", - "daily.patterns.create": "Create a Day\u{26A1}", - "daily.patterns.create.name.required": "Day name is required\u{26A1}", - "daily.patterns.create.name.exists": "Day already exists\u{26A1}", - "daily.patterns.edit": "Details/Edit Day\u{26A1}", - "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", - "daily.patterns.start.hour": "Start Hour\u{26A1}", - "daily.patterns.end.hour": "End Hour\u{26A1}", - "date.range": 'Plage de dates', - "day": "Journée", - "day.of.week": "Jour de la Semaine", - "day.sunday": "Dimanche", + "day": "Jour", + "day.create": "Créer un Jour", + "day.create.name.required": "Le nom du jour est requis", + "day.create.name.exists": "Le jour existe déjà", + "day.edit": "Détails/Modifier Jour", + "day.end.hour": "Heure de fin", + "day.friday": "Vendredi", "day.monday": "Lundi", + "day.of.week": "Jour de la semaine", + "day.saturday": "Samedi", + "day.slope.intercept.zero": "La pente et l'ordonnée à l'origine sont toutes deux nulles. Voulez-vous vraiment enregistrer ce modèle ?", + "day.start.hour": "Heure de début", + "day.sunday": "Dimanche", + "day.thursday": "Jeudi", "day.tuesday": "Mardi", "day.wednesday": "Mercredi", - "day.thursday": "Jeudi", - "day.friday": "Vendredi", - "day.saturday": "Samedi", "days": "Journées", "days.enter": "Enter in days and then hit enter\u{26A1}", "decreasing": "decreasing\u{26A1}", @@ -874,7 +874,9 @@ 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.dayview": "This page shows information on daily patterns. 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.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}", @@ -1065,6 +1067,7 @@ 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}", @@ -1354,24 +1357,23 @@ const LocaleTranslationData = { "csvMeters": "CSV Meters\u{26A1}", "csvReadings": "CSV Readings\u{26A1}", "custom.value": "Valor personalizado", - "daily.patterns": "Patterns - Daily\u{26A1}", - "daily.patterns.create": "Create a Day\u{26A1}", - "daily.patterns.create.name.required": "Day name is required\u{26A1}", - "daily.patterns.create.name.exists": "Day already exists\u{26A1}", - "daily.patterns.edit": "Details/Edit Day\u{26A1}", - "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", - "daily.patterns.start.hour": "Start Hour\u{26A1}", - "daily.patterns.end.hour": "End Hour\u{26A1}", "date.range": 'Rango de fechas', "day": "Día", - "day.of.week": "Día de la Semana", - "day.sunday": "Domingo", + "day.create": "Crear un Día", + "day.create.name.required": "El nombre del día es requerido", + "day.create.name.exists": "El día ya existe", + "day.edit": "Detalles/Editar Día", + "day.end.hour": "Hora de finalización", + "day.friday": "Viernes", "day.monday": "Lunes", + "day.of.week": "Día de la semana", + "day.saturday": "Sábado", + "day.slope.intercept.zero": "Tanto la pendiente como la intersección son cero. ¿Seguro que quieres guardar este patrón?", + "day.start.hour": "Hora de inicio", + "day.sunday": "Domingo", + "day.thursday": "Jueves", "day.tuesday": "Martes", "day.wednesday": "Miércoles", - "day.thursday": "Jueves", - "day.friday": "Viernes", - "day.saturday": "Sábado", "days": "Días", "days.enter": "Ingrese los días y presione \"Enter\"", "decreasing": "decreciente", @@ -1483,7 +1485,9 @@ 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.dayview": "This page shows information on daily patterns. 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.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.", @@ -1674,6 +1678,7 @@ 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", From 0b2329c20e24c13055f88b0dad0a030356d06f80 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Fri, 1 Aug 2025 23:30:01 -0600 Subject: [PATCH 039/170] fixed space indetations --- .../CreateConversionModalComponent.tsx | 116 +++++++++--------- .../days/CreateDayModalComponent.tsx | 58 ++++----- 2 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index 40433d8e6c..5b06a93531 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -86,67 +86,67 @@ export default function CreateConversionModalComponent() { ); const handleStringChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - if (name === 'overallConversionNote') { - setConversionState(prev => ({ - ...prev, - overallConversion: { - ...prev.overallConversion, - note: value - } - })); - } else if (name === 'initialConversionNote') { - setConversionState(prev => ({ - ...prev, - initialConversion: { - ...prev.initialConversion, - note: value - } - })); - } + const { name, value } = e.target; + if (name === 'overallConversionNote') { + setConversionState(prev => ({ + ...prev, + overallConversion: { + ...prev.overallConversion, + note: value + } + })); + } else if (name === 'initialConversionNote') { + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + note: value + } + })); + } }; - const handleNumberChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - const newValue = Number(value); + const handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newValue = Number(value); - setConversionState(prev => { - if (name === 'sourceId') { - return { - ...prev, - overallConversion: { - ...prev.overallConversion, - sourceId: newValue - }, - initialConversion: { - ...prev.initialConversion, - sourceId: newValue - }, - destinationOptions: defaultValues.destinationOptions.filter(destination => destination.id !== newValue) - }; - } else if (name === 'destinationId') { - return { - ...prev, - overallConversion: { - ...prev.overallConversion, - destinationId: newValue - }, - initialConversion: { - ...prev.initialConversion, - destinationId: newValue - }, - sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) - }; - } else { - return { - ...prev, - initialConversion: { - ...prev.initialConversion, - [name]: newValue - } - }; - } - }); + setConversionState(prev => { + if (name === 'sourceId') { + return { + ...prev, + overallConversion: { + ...prev.overallConversion, + sourceId: newValue + }, + initialConversion: { + ...prev.initialConversion, + sourceId: newValue + }, + destinationOptions: defaultValues.destinationOptions.filter(destination => destination.id !== newValue) + }; + } else if (name === 'destinationId') { + return { + ...prev, + overallConversion: { + ...prev.overallConversion, + destinationId: newValue + }, + initialConversion: { + ...prev.initialConversion, + destinationId: newValue + }, + sourceOptions: defaultValues.sourceOptions.filter(source => source.id !== newValue) + }; + } else { + return { + ...prev, + initialConversion: { + ...prev.initialConversion, + [name]: newValue + } + }; + } + }); }; const handleBooleanChange = (e: React.ChangeEvent) => { diff --git a/src/client/app/components/days/CreateDayModalComponent.tsx b/src/client/app/components/days/CreateDayModalComponent.tsx index 4434f9a6f1..b0e3437ed2 100644 --- a/src/client/app/components/days/CreateDayModalComponent.tsx +++ b/src/client/app/components/days/CreateDayModalComponent.tsx @@ -43,17 +43,17 @@ export default function CreateDayModalComponent() { // Handlers for each type of input change const [patternState, setPatternState] = useState({ - Day: { + Day: { name: defaultValues.name, - note: defaultValues.DayNote - }, - initialPattern: { - slope: defaultValues.slope, - intercept: defaultValues.intercept, + note: defaultValues.DayNote + }, + initialPattern: { + slope: defaultValues.slope, + intercept: defaultValues.intercept, startHour: defaultValues.startHour, endHour: defaultValues.endHour, - note: defaultValues.initialPatternNote - } + note: defaultValues.initialPatternNote + } }); // Check if the daily pattern is valid @@ -65,24 +65,24 @@ export default function CreateDayModalComponent() { ); const handleStringChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - if (name === 'DayNote') { - setPatternState(prev => ({ - ...prev, - Day: { - ...prev.Day, - note: value - } - })); - } else if (name === 'initialPatternNote') { - setPatternState(prev => ({ - ...prev, - initialPattern: { - ...prev.initialPattern, - note: value - } - })); - } else { + const { name, value } = e.target; + if (name === 'DayNote') { + setPatternState(prev => ({ + ...prev, + Day: { + ...prev.Day, + note: value + } + })); + } else if (name === 'initialPatternNote') { + setPatternState(prev => ({ + ...prev, + initialPattern: { + ...prev.initialPattern, + note: value + } + })); + } else { setPatternState(prev => ({ ...prev, Day: { @@ -93,9 +93,9 @@ export default function CreateDayModalComponent() { } }; - const handleNumberChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - const newValue = Number(value); + const handleNumberChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + const newValue = Number(value); if (name === 'slope') { setPatternState(prev => ({ ...prev, From d7f33a6173edcf1fdbbda3e95efd9024ece369ba Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Sat, 2 Aug 2025 11:13:41 -0700 Subject: [PATCH 040/170] Clean up EditConversionModal: fix delete edge cases, error handling, and UI feedback Signed-off-by: PRINCESANCHEZ --- .../EditConversionModalComponent.tsx | 163 ++++++++++++------ src/client/app/translations/data.ts | 42 +++-- 2 files changed, 136 insertions(+), 69 deletions(-) diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index b33b29bbe3..fb98c5879d 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -21,9 +21,11 @@ import { TrueFalseType } from '../../types/items'; import { ConversionData } from '../../types/redux/conversions'; import { ConversionSegmentData } from '../../types/redux/conversionSegments'; import { UnitData, UnitType } from '../../types/redux/units'; +import { showErrorNotification } from 'utils/notifications'; import { useTranslate } from '../../redux/componentHooks'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; +import { toast } from 'react-toastify'; interface EditConversionModalComponentProps { show: boolean; @@ -35,11 +37,6 @@ interface EditConversionModalComponentProps { handleClose: () => void; } -type EditableSegment = ConversionSegmentData & { - originalStartTime: string; - originalEndTime: string; -}; - const PER_TABLE = 10; /** @@ -55,37 +52,37 @@ export default function EditConversionModalComponent(props: EditConversionModalC const [editSegment] = conversionSegmentsApi.useEditConversionSegmentMutation(); const [addSegment] = conversionSegmentsApi.useAddConversionSegmentMutation(); const [deleteSegment] = conversionSegmentsApi.useDeleteConversionSegmentMutation(); - const unitDataById = useAppSelector(selectUnitDataById); - const meterDataById = useAppSelector(selectMeterDataById); - const conversionDetails = useAppSelector(selectConversionsDetails); - const { data: segments = [], refetch: refetchSegments } = conversionSegmentsApi.useGetConversionSegmentByConversionQuery ({ + const getSegments = conversionSegmentsApi.useGetConversionSegmentByConversionQuery({ sourceId: props.conversion.sourceId, destinationId: props.conversion.destinationId }); - const { data: weekPatterns = [] } = weeksApi.useGetWeeksQuery(); - + const getWeeks = weeksApi.useGetWeeksQuery(); + const unitDataById = useAppSelector(selectUnitDataById); + const meterDataById = useAppSelector(selectMeterDataById); + const conversionDetails = useAppSelector(selectConversionsDetails); // Set existing conversion values const values = { ...props.conversion }; /* State */ // Handlers for each type of input change const [state, setState] = useState(values); - const [editingSegment, setEditingSegment] = useState(null); + // 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 [actionDirection, setActionDirection] = useState<'earlier' | 'later' | null>(null); const [selectedSegment, setSelectedSegment] = useState(null); const [actionDatetime, setActionDatetime] = useState(''); - const [fieldErrors, setFieldErrors] = useState<{ - startTimeError?: string; - endTimeError?: string; - splitTimeError?: string; - segmentValidationError?: string; - dateTimeError?: string; - }>({}); + const [fieldErrors, setFieldErrors] = useState<{ segmentTimeError?: string; }>({}); 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 refetchSegments = getSegments.refetch; + const weekPatterns = getWeeks.data ?? []; + const totalPages = Math.ceil(segments.length / PER_TABLE); const handleStringChange = (e: React.ChangeEvent) => { @@ -98,7 +95,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC const handleNumberChange = (e: React.ChangeEvent) => { setEditingSegment(prev => { - if (!prev) return prev; + // Safety check: segment should always be set when editing, but added to prevent issues from future refactors + if (!prev) { + return prev; + } return { ...prev, [e.target.name]: Number(e.target.value), // dynamically sets either slope or intercept @@ -111,23 +111,34 @@ export default function EditConversionModalComponent(props: EditConversionModalC const handlePatternChange = (e: React.ChangeEvent) => { const selectedPattern = e.target.value; setEditingSegment(prev => { - if (!prev) return prev; + if (!prev) { + return prev; + } - const slope = selectedPattern === 'No Pattern' ? 0 : prev.slope; - const intercept = selectedPattern === 'No Pattern' ? 0 : prev.intercept; - return { - ...prev, - weekPatternsId: selectedPattern === 'No Pattern' ? null : Number(selectedPattern), - slope, - intercept - }; + if (selectedPattern === 'No Pattern') { + return { + ...prev, + weekPatternsId: null, + slope: 0, + intercept: 0 + }; + } else { + return { + ...prev, + weekPatternsId: Number(selectedPattern) + }; + } }); }; // Updates the note field for the currently edited segment + // Separate handler needed since segment editing uses different state const handleSegmentNoteChange = (e: React.ChangeEvent) => { setEditingSegment(prev => { - if (!prev) return prev; + // Safety check: segment should always be set when editing, but added to prevent issues from future refactors + if (!prev) { + return prev; + } return {...prev, note: e.target.value}; }); }; @@ -144,7 +155,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC const validFormat = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; if (!validFormat.test(e.target.value.trim())) { setFieldErrors(prev => ({ ...prev, - dateTimeError: intl.formatMessage( + segmentTimeError: intl.formatMessage( { id: 'conversion.error.datetime.invalid' }, {field: e.target.name} ) @@ -165,7 +176,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Handle if editing a segment if (editingSegment && (e.target.name === 'startTime' || e.target.name === 'endTime')) { setEditingSegment(prev => { - if (!prev) return prev; + // Safety check: segment should always be set when editing, but added to prevent issues from future refactors + if (!prev) { + return prev; + } return { ...prev, [e.target.name]: e.target.value @@ -189,7 +203,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC if (!isStartTimeValid || !isEndTimeValid) { - setFieldErrors(prev => ({ ...prev, splitTimeError: translate('conversion.error.segment.splitTime')})); + setFieldErrors(prev => ({ ...prev, segmentTimeError: translate('conversion.error.segment.splitTime')})); return; } @@ -219,6 +233,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC endTime: actionDatetime, slope: 0, intercept: 0, + weekPatternsId: null, note: '' }); // Later segment keeps original values @@ -241,6 +256,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC endTime: selectedSegment.endTime, slope: 0, intercept: 0, + weekPatternsId: null, note: '' }); } @@ -250,10 +266,16 @@ export default function EditConversionModalComponent(props: EditConversionModalC setActionDatetime(''); await refetchSegments(); } catch (error) { - console.error(error); + showErrorNotification( + intl.formatMessage({ id: 'conversion.segment.split.error' }), + toast.POSITION.TOP_RIGHT, + 5000 + ); + await refetchSegments(); } }; + // TODO: Adjust the adjacent segment's time range to maintain continuity and avoid gaps after deleting the current segment // Deletes the selected segment and updates the adjacent segment's time range // to preserve continuous coverage with no time gaps or overlaps const handleDeleteSegment = async () => { @@ -305,12 +327,17 @@ export default function EditConversionModalComponent(props: EditConversionModalC startTime: selectedSegment.startTime, endTime: selectedSegment.endTime }); + await refetchSegments(); setSelectedSegment(null); setActionDirection(null); // Refresh the segments list - await refetchSegments(); } catch (error) { - console.error(error); + showErrorNotification( + intl.formatMessage({ id: 'conversion.segment.delete.error' }), + toast.POSITION.TOP_RIGHT, + 5000 + ); + await refetchSegments(); } }; /* End State */ @@ -546,8 +573,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC // If the editing segment has no slope, no intercept, and no pattern applied, // show a warning modal to confirm saving a "neutral" segment with no effect. if (editingSegment && editingSegment.slope === 0 && editingSegment.intercept === 0 && editingSegment.weekPatternsId === null) { - setFieldErrors(prev => ({ ...prev, segmentValidationError: translate('conversion.error.segment.neutral')})); - return; + console.warn('Saving a neutral segment'); } // If editing a segment's time range, ensure there are no gaps or overlaps with adjacent segments, // but only if the range is bounded (i.e., not -infinity or infinity). @@ -570,7 +596,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Check for gaps/overlaps with previous segment if (previous && !moment(editingSegment.startTime).isSame(previous.endTime)) { setFieldErrors(prev =>({ ...prev, - startTimeError: intl.formatMessage( + segmentTimeError: intl.formatMessage( { id: 'conversion.error.segment.startTimeMismatch' }, { endTime: previous.endTime } ) @@ -580,7 +606,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Check for gaps/overlaps with next segment if (next && !moment(editingSegment.endTime).isSame(next.startTime)) { setFieldErrors(prev =>({ ...prev, - endTimeError: intl.formatMessage( + segmentTimeError: intl.formatMessage( { id: 'conversion.error.segment.endTimeMismatch' }, { startTime: next.startTime } ) @@ -599,7 +625,11 @@ export default function EditConversionModalComponent(props: EditConversionModalC }); setShowEditSegmentModal(false); } catch (error) { - console.error('Failed to edit segment:', error); + showErrorNotification( + intl.formatMessage({ id: 'conversion.segment.save.error' }), + toast.POSITION.TOP_RIGHT, + 5000 + ); } } @@ -671,10 +701,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC placeholder='YYYY-MM-DD HH:MM:SS' value={editingSegment?.startTime ?? ''} onChange={handleDatetimeChange} - invalid={!!fieldErrors.startTimeError} + invalid={!!fieldErrors.segmentTimeError} disabled={editingSegment?.startTime === '-infinity'} /> - {fieldErrors.startTimeError} + {fieldErrors.segmentTimeError} @@ -684,10 +714,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC placeholder='YYYY-MM-DD HH:MM:SS' value={editingSegment?.endTime ?? ''} onChange={handleDatetimeChange} - invalid={!!fieldErrors.endTimeError} + invalid={!!fieldErrors.segmentTimeError} disabled={editingSegment?.endTime === 'infinity'} /> - {fieldErrors.endTimeError} + {fieldErrors.segmentTimeError} @@ -696,10 +726,8 @@ export default function EditConversionModalComponent(props: EditConversionModalC type='number' value={editingSegment?.slope ?? ''} onChange={handleNumberChange} - invalid={!!fieldErrors.segmentValidationError} disabled={editingSegment?.weekPatternsId !== null} /> - {fieldErrors.segmentValidationError} @@ -708,10 +736,8 @@ export default function EditConversionModalComponent(props: EditConversionModalC type='number' value={editingSegment?.intercept ?? ''} onChange={handleNumberChange} - invalid={!!fieldErrors.segmentValidationError} disabled={editingSegment?.weekPatternsId !== null} /> - {fieldErrors.segmentValidationError} @@ -721,14 +747,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC type='select' value={editingSegment?.weekPatternsId ?? ''} onChange={handlePatternChange} - invalid={!!fieldErrors.segmentValidationError} > {weekPatterns.map(pattern => ( ))} - {fieldErrors.segmentValidationError} @@ -740,6 +764,17 @@ export default function EditConversionModalComponent(props: EditConversionModalC + {editingSegment && + editingSegment.slope === 0 && + editingSegment.intercept === 0 && + editingSegment.weekPatternsId === null && ( +
+ +
+ )} {/* On click calls the function handleSaveChanges in this component */} -
diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index f5f7b0d7cd..2947c10092 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -79,6 +79,8 @@ const LocaleTranslationData = { "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.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:", @@ -92,21 +94,24 @@ const LocaleTranslationData = { "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.intercept": "Intercept", "conversion.is.deleted": "Conversion removed from database", "conversion.overall": "Overall Conversion", "conversion.overall.delete": "Delete Overall Conversion", "conversion.overall.discard": "Discard Overall Changes", "conversion.overall.save": "Save Overall Conversion", "conversion.overall.subtitle": "This section defines the overall conversion between units.", - "conversion.pattern": "Pattern:", + "conversion.pattern": "Pattern", "conversion.pattern.no": "No Pattern", "conversion.represent": "Conversion Represent:", + "conversion.segment.delete.error": "An error occurred while deleting the segment. Please try again.", + "conversion.segment.split.error": "An error occurred while splitting the segment. Please try again.", + "conversion.segment.save.error": "An error occurred while saving the segment. Please try again.", "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": "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.prompt": "Enter the datetime (YYYY-MM-DD HH:mm:ss) to split the segment:", @@ -120,6 +125,7 @@ const LocaleTranslationData = { "conversion.table.split.later": "Split Later\u2193", "conversion.time.end": "End time", "conversion.time.start": "Start time", + "conversion.warning.segment.neutral": "Warning: This segment has no slope, intercept, or pattern. It will not affect conversions.", "conversions": "Conversions", "ConversionType.conversion": "conversion", "create.conversion": "Create a Conversion", @@ -464,7 +470,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. ", @@ -712,6 +718,8 @@ const LocaleTranslationData = { "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.segment.earlier": "Êtes-vous sûr de vouloir supprimer le segment de {start} à {end} et mettre à jour le segment précédent?\u{26A1}", + "conversion.delete.segment.later": "Êtes-vous sûr de vouloir supprimer le segment de {start} à {end} et mettre à jour le segment suivant?\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.unit.orphan": "Deleting this unit conversion will orphan unit\u{26A1}", "conversion.destination": "Destination:\u{26A1}", @@ -725,21 +733,24 @@ const LocaleTranslationData = { "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.intercept": "Intercept\u{26A1}", "conversion.is.deleted": "Conversion removed from database\u{26A1}", "conversion.overall": "Conversion Globale\u{26A1}", "conversion.overall.delete": "Supprimer la conversion globale\u{26A1}", "conversion.overall.discard": "Annuler les modifications globales\u{26A1}", "conversion.overall.save": "Enregistrer la conversion globale\u{26A1}", "conversion.overall.subtitle": "Cette section définit la conversion globale entre les unités.\u{26A1}", - "conversion.pattern": "Pattern:\u{26A1}", + "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. Veuillez réessayer.\u{26A1}", + "conversion.segment.split.error": "Une erreur est survenue lors de la scission du segment. Veuillez réessayer.\u{26A1}", + "conversion.segment.save.error": "Une erreur est survenue lors de l’enregistrement du segment. Veuillez réessayer.\u{26A1}", "conversion.segments.table": "Table des segments de conversion\u{26A1}", "conversion.segments.table.subtitle": "Cette section montre comment la conversion fonctionne sur des périodes spécifiques.\u{26A1}", "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": "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.prompt": "Entrez la date et l'heure (AAAA-MM-JJ HH:mm:ss) pour diviser le segment:\u{26A1}", @@ -753,6 +764,7 @@ const LocaleTranslationData = { "conversion.table.split.later": "Diviser après\u2193\u{26A1}", "conversion.time.start": "Heure de début\u{26A1}", "conversion.time.end": "Heure de fin\u{26A1}", + "conversion.warning.segment.neutral": "Attention : Ce segment n’a ni pente, ni interception, ni motif. Il n’aura aucun effet sur les conversions.\u{26A1}", "conversions": "Conversions\u{26A1}", "ConversionType.conversion": "conversion\u{26A1}", "create.conversion": "Create a Conversion\u{26A1}", @@ -1097,7 +1109,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. ", @@ -1345,6 +1357,8 @@ const LocaleTranslationData = { "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.segment.earlier": "¿Está seguro de que desea eliminar el segmento de {start} a {end} y actualizar el segmento anterior?\u{26A1}", + "conversion.delete.segment.later": "¿Está seguro de que desea eliminar el segmento de {start} a {end} y actualizar el siguiente segmento?\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.unit.orphan": "Deleting this unit conversion will orphan unit\u{26A1}", "conversion.destination": "Destinación", @@ -1358,21 +1372,24 @@ const LocaleTranslationData = { "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.intercept": "Interceptar", "conversion.is.deleted": "Se borró la conversión en la base de datos", "conversion.overall": "Conversión General\u{26A1}", "conversion.overall.delete": "Eliminar la conversión general\u{26A1}", "conversion.overall.discard": "Descartar los cambios generales\u{26A1}", "conversion.overall.save": "Guardar la conversión general\u{26A1}", "conversion.overall.subtitle": "Esta sección define la conversión general entre unidades.\u{26A1}", - "conversion.pattern": "Pattern:\u{26A1}", + "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. Por favor, inténtelo de nuevo.\u{26A1}", + "conversion.segment.split.error": "Ocurrió un error al dividir el segmento. Por favor, inténtelo de nuevo.\u{26A1}", + "conversion.segment.save.error": "Ocurrió un error al guardar el segmento. Por favor, inténtelo de nuevo.\u{26A1}", "conversion.segments.table": "Tabla de segmentos de conversión\u{26A1}", "conversion.segments.table.subtitle": "Esta sección muestra cómo se comporta la conversión durante períodos específicos.\u{26A1}", "conversion.select.destination": "Seleccionar una unidad de destinación", "conversion.select.source": "Seleccionar una unidad de fuente", - "conversion.slope": "Pendiente:", + "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.prompt": "Ingrese la fecha y hora (AAAA-MM-DD HH:mm:ss) para dividir el segmento:\u{26A1}", @@ -1386,6 +1403,7 @@ const LocaleTranslationData = { "conversion.table.split.later": "Dividir después\u2193\u{26A1}", "conversion.time.start": "Hora de inicio\u{26A1}", "conversion.time.end": "Hora de finalización\u{26A1}", + "conversion.warning.segment.neutral": "Advertencia: Este segmento no tiene pendiente, intersección ni patrón. No tendrá efecto en las conversiones.\u{26A1}", "conversions": "Conversiones", "ConversionType.conversion": "conversión", "create.conversion": "Crear una conversión", @@ -1730,7 +1748,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. ", From c927aced3e035e100deb86a3fecaff7aa9a382d4 Mon Sep 17 00:00:00 2001 From: Gomez-V Date: Sat, 2 Aug 2025 18:01:37 -0700 Subject: [PATCH 041/170] added French and Spanish translations for daily.patterns --- src/client/app/translations/data.ts | 37 ++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 8b83553e01..122f7d6102 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -164,7 +164,12 @@ const LocaleTranslationData = { "daily.patterns.delete.confirm": "Are you sure you want to delete this day?", "daily.patterns.delete.confirm.button": "Delete Day", "daily.patterns.delete.error": "Failed to delete day. Please try again.", - "daily.patterns.segments.table": "Segments Table", + "daily.patterns.segments.table.title": "Segments Table", + "daily.patterns.segments.table.timeRange": "Time Range", + "daily.patterns.segments.table.slope": "Slope", + "daily.patterns.segments.table.intercept": "Intercept", + "daily.patterns.segments.table.edit": "Edit", + "daily.patterns.delete.button": "Delete Day", "date.range": 'Date Range', "day": "Day", "day.of.week": "Day of Week", @@ -796,6 +801,18 @@ const LocaleTranslationData = { "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", + "daily.patterns.edit.success": "Jour modifié avec succès.", + "daily.patterns.edit.error": "Échec de la modification du jour. Veuillez réessayer.", + "daily.patterns.delete.success": "Jour supprimé avec succès.", + "daily.patterns.delete.confirm": "Êtes-vous sûr de vouloir supprimer ce jour ?", + "daily.patterns.delete.confirm.button": "Supprimer le jour", + "daily.patterns.delete.error": "Échec de la suppression du jour. Veuillez réessayer.", + "daily.patterns.segments.table.title": "Tableau des segments", + "daily.patterns.segments.table.timeRange": "Plage horaire", + "daily.patterns.segments.table.slope": "Pente", + "daily.patterns.segments.table.intercept": "Ordonnée à l'origine", + "daily.patterns.segments.table.edit": "Modifier", + "daily.patterns.delete.button": "Supprimer le jour", "date.range": 'Plage de dates', "day": "Journée", "day.of.week": "Jour de la Semaine", @@ -854,6 +871,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}", @@ -1162,6 +1180,8 @@ const LocaleTranslationData = { "sort": "Sort Order\u{26A1}", "split.earlier": "Diviser\u2191\u{26A1}", "split.later": "Diviser\u2193\u{26A1}", + "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}", @@ -1424,6 +1444,18 @@ const LocaleTranslationData = { "daily.patterns.slope.intercept.zero": "Both the slope and the intercept are zero. Are you sure you want to save this pattern?\u{26A1}", "daily.patterns.start.hour": "Start Hour\u{26A1}", "daily.patterns.end.hour": "End Hour\u{26A1}", + "daily.patterns.edit.success": "Día editado con éxito.", + "daily.patterns.edit.error": "No se pudo editar el día. Por favor, inténtelo de nuevo.", + "daily.patterns.delete.success": "Día eliminado con éxito.", + "daily.patterns.delete.confirm": "¿Está seguro de que desea eliminar este día?", + "daily.patterns.delete.confirm.button": "Eliminar día", + "daily.patterns.delete.error": "No se pudo eliminar el día. Por favor, inténtelo de nuevo.", + "daily.patterns.segments.table.title": "Tabla de segmentos", + "daily.patterns.segments.table.timeRange": "Rango de tiempo", + "daily.patterns.segments.table.slope": "Pendiente", + "daily.patterns.segments.table.intercept": "Intersección", + "daily.patterns.segments.table.edit": "Editar", + "daily.patterns.delete.button": "Eliminar día", "date.range": 'Rango de fechas', "day": "Día", "day.of.week": "Día de la Semana", @@ -1482,6 +1514,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}.", @@ -1790,6 +1823,8 @@ const LocaleTranslationData = { "sort": "Sort Order\u{26A1}", "split.earlier": "Dividir\u2191\u{26A1}", "split.later": "Dividir\u2193\u{26A1}", + "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", From 2a190d480026f2c6394baeb055be5d36001c79c0 Mon Sep 17 00:00:00 2001 From: Triet Huynh Date: Sat, 2 Aug 2025 20:15:36 -0600 Subject: [PATCH 042/170] made necessary naming and logic adjustments per reivew --- .../CreateConversionModalComponent.tsx | 74 ++++++++----------- .../days/CreateDayModalComponent.tsx | 56 +++++++------- src/client/app/redux/api/conversionsApi.ts | 2 +- src/client/app/redux/api/daysApi.ts | 2 +- .../app/redux/selectors/adminSelectors.ts | 15 ++-- 5 files changed, 69 insertions(+), 80 deletions(-) diff --git a/src/client/app/components/conversion/CreateConversionModalComponent.tsx b/src/client/app/components/conversion/CreateConversionModalComponent.tsx index 5b06a93531..369ee233db 100644 --- a/src/client/app/components/conversion/CreateConversionModalComponent.tsx +++ b/src/client/app/components/conversion/CreateConversionModalComponent.tsx @@ -58,9 +58,6 @@ export default function CreateConversionModalComponent() { note: defaultValues.overallConversionNote }, initialConversion: { - sourceId: defaultValues.sourceId, - destinationId: defaultValues.destinationId, - bidirectional: defaultValues.bidirectional, slope: defaultValues.slope, intercept: defaultValues.intercept, pattern: defaultValues.weeklyPattern, @@ -74,18 +71,10 @@ export default function CreateConversionModalComponent() { // If the currently selected conversion is valid // TODO: Add a check for the weekly pattern as well. const [validConversion, reason] = useAppSelector(state => - selectIsValidConversion(state, { - sourceId: conversionState.initialConversion.sourceId, - destinationId: conversionState.initialConversion.destinationId, - bidirectional: conversionState.initialConversion.bidirectional, - slope: conversionState.initialConversion.slope, - intercept: conversionState.initialConversion.intercept, - //pattern: conversionState.initialConversion.pattern, - note: conversionState.initialConversion.note - }) + selectIsValidConversion(state, conversionState) ); - const handleStringChange = (e: React.ChangeEvent) => { + const handleNoteChange = (e: React.ChangeEvent) => { const { name, value } = e.target; if (name === 'overallConversionNote') { setConversionState(prev => ({ @@ -106,7 +95,7 @@ export default function CreateConversionModalComponent() { } }; - const handleNumberChange = (e: React.ChangeEvent) => { + const handleConversionNumberFieldChange = (e: React.ChangeEvent) => { const { name, value } = e.target; const newValue = Number(value); @@ -149,7 +138,7 @@ export default function CreateConversionModalComponent() { }); }; - const handleBooleanChange = (e: React.ChangeEvent) => { + const handleBidirectionalChange = (e: React.ChangeEvent) => { const value = e.target.value === 'true'; setConversionState(prev => ({ ...prev, @@ -164,17 +153,19 @@ export default function CreateConversionModalComponent() { })); }; - const handleInitialPatternChange = (e: React.ChangeEvent) => { - const selectedValue = e.target.value; - setConversionState(prev => ({ - ...prev, - initialConversion: { - ...prev.initialConversion, - pattern: selectedValue, // week ID as string or "No Pattern" - slope: selectedValue === 'No Pattern' ? prev.initialConversion.slope : 0, - intercept: selectedValue === 'No Pattern' ? prev.initialConversion.intercept : 0 - } - })); + const handlePatternChange = (e: React.ChangeEvent) => { + const selectedValue = Number(e.target.value); + const isNoPattern = selectedValue === -99; + + setConversionState(prev => ({ + ...prev, + initialConversion: { + ...prev.initialConversion, + pattern: selectedValue, + slope: isNoPattern ? prev.initialConversion.slope : 0, + intercept: isNoPattern ? prev.initialConversion.intercept : 0 + } + })); }; /* End State */ @@ -196,7 +187,7 @@ export default function CreateConversionModalComponent() { //Close the warning modal setShowWarningModal(false); const weekPatternsId = - conversionState.initialConversion.pattern === 'No Pattern' + conversionState.initialConversion.pattern === -99 ? undefined : Number(conversionState.initialConversion.pattern); const payload = { @@ -228,9 +219,6 @@ export default function CreateConversionModalComponent() { note: defaultValues.overallConversionNote }, initialConversion: { - sourceId: defaultValues.sourceId, - destinationId: defaultValues.destinationId, - bidirectional: defaultValues.bidirectional, slope: defaultValues.slope, intercept: defaultValues.intercept, pattern: defaultValues.weeklyPattern, @@ -253,7 +241,7 @@ export default function CreateConversionModalComponent() { // Close modal first to avoid repeat clicks setShowModal(false); const weekPatternsId = - conversionState.initialConversion.pattern === 'No Pattern' + conversionState.initialConversion.pattern === -99 ? undefined : Number(conversionState.initialConversion.pattern); const payload = { @@ -314,7 +302,7 @@ export default function CreateConversionModalComponent() { name='sourceId' type='select' value={conversionState.overallConversion.sourceId} - onChange={e => handleNumberChange(e)} + onChange={e => handleConversionNumberFieldChange(e)} disabled={isFetchingWeeks} invalid={conversionState.overallConversion.sourceId === -999}> { + {weeks.map(week => ( ))} @@ -451,7 +439,7 @@ export default function CreateConversionModalComponent() { id='initialConversionNote' name='initialConversionNote' type='textarea' - onChange={e => handleStringChange(e)} + onChange={e => handleNoteChange(e)} value={conversionState.initialConversion.note} /> diff --git a/src/client/app/components/days/CreateDayModalComponent.tsx b/src/client/app/components/days/CreateDayModalComponent.tsx index b0e3437ed2..71d0611cc3 100644 --- a/src/client/app/components/days/CreateDayModalComponent.tsx +++ b/src/client/app/components/days/CreateDayModalComponent.tsx @@ -47,12 +47,12 @@ export default function CreateDayModalComponent() { name: defaultValues.name, note: defaultValues.DayNote }, - initialPattern: { + initialSegment: { slope: defaultValues.slope, intercept: defaultValues.intercept, startHour: defaultValues.startHour, endHour: defaultValues.endHour, - note: defaultValues.initialPatternNote + segmentNote: defaultValues.initialSegmentNote } }); @@ -74,11 +74,11 @@ export default function CreateDayModalComponent() { note: value } })); - } else if (name === 'initialPatternNote') { + } else if (name === 'initialSegmentNote') { setPatternState(prev => ({ ...prev, - initialPattern: { - ...prev.initialPattern, + initialSegment: { + ...prev.initialSegment, note: value } })); @@ -99,16 +99,16 @@ export default function CreateDayModalComponent() { if (name === 'slope') { setPatternState(prev => ({ ...prev, - initialPattern: { - ...prev.initialPattern, + initialSegment: { + ...prev.initialSegment, slope: newValue } })); } else if (name === 'intercept') { setPatternState(prev => ({ ...prev, - initialPattern: { - ...prev.initialPattern, + initialSegment: { + ...prev.initialSegment, intercept: newValue } })); @@ -122,10 +122,10 @@ export default function CreateDayModalComponent() { // Add the new pattern and update the store addDayMutation({ name: patternState.Day.name, - slope: patternState.initialPattern.slope, - intercept: patternState.initialPattern.intercept, + slope: patternState.initialSegment.slope, + intercept: patternState.initialSegment.intercept, note: patternState.Day.note, - segmentNote: patternState.initialPattern.note + segmentNote: patternState.initialSegment.segmentNote }); // Reset the state to default values resetState(); @@ -145,12 +145,12 @@ export default function CreateDayModalComponent() { name: defaultValues.name, note: defaultValues.DayNote }, - initialPattern: { + initialSegment: { slope: defaultValues.slope, intercept: defaultValues.intercept, startHour: defaultValues.startHour, endHour: defaultValues.endHour, - note: defaultValues.initialPatternNote + segmentNote: defaultValues.initialSegmentNote } }); }; @@ -159,7 +159,7 @@ export default function CreateDayModalComponent() { // Submit const handleSubmit = () => { // Show warning modal if slope and intercept are both 0 - if (patternState.initialPattern.slope === 0 && patternState.initialPattern.intercept === 0) { + if (patternState.initialSegment.slope === 0 && patternState.initialSegment.intercept === 0) { setWarningMessage(translate('day.slope.intercept.zero')); setShowWarningModal(true); } else { @@ -168,10 +168,10 @@ export default function CreateDayModalComponent() { // Add the new pattern and update the store addDayMutation({ name: patternState.Day.name, - slope: patternState.initialPattern.slope, - intercept: patternState.initialPattern.intercept, + slope: patternState.initialSegment.slope, + intercept: patternState.initialSegment.intercept, note: patternState.Day.note, - segmentNote: patternState.initialPattern.note + segmentNote: patternState.initialSegment.segmentNote }); // Reset the state to default values resetState(); @@ -218,7 +218,7 @@ export default function CreateDayModalComponent() { type='text' onChange={e => handleStringChange(e)} value={patternState.Day.name} - invalid={!patternState.Day.name || patternState.Day.name.trim() === ''} + invalid={!patternState.Day.name || patternState.Day.name.trim() === ''} // TODO: OED needs to decide how to trim names universally required /> @@ -236,7 +236,7 @@ export default function CreateDayModalComponent() { value={patternState.Day.note} /> {/*Initial pattern*/} -
+
@@ -248,7 +248,7 @@ export default function CreateDayModalComponent() { id='slope' name='slope' type='number' - value={patternState.initialPattern.slope} + value={patternState.initialSegment.slope} onChange={e => handleNumberChange(e)} /> @@ -260,7 +260,7 @@ export default function CreateDayModalComponent() { id='intercept' name='intercept' type='number' - value={patternState.initialPattern.intercept} + value={patternState.initialSegment.intercept} onChange={e => handleNumberChange(e)} /> @@ -274,7 +274,7 @@ export default function CreateDayModalComponent() { id='startHour' name='startHour' type='number' - value={patternState.initialPattern.startHour} + value={patternState.initialSegment.startHour} disabled readOnly /> @@ -288,7 +288,7 @@ export default function CreateDayModalComponent() { id='endHour' name='endHour' type='number' - value={patternState.initialPattern.endHour} + value={patternState.initialSegment.endHour} disabled readOnly /> @@ -299,17 +299,17 @@ export default function CreateDayModalComponent() { handleStringChange(e)} - value={patternState.initialPattern.note} /> + value={patternState.initialSegment.segmentNote} /> { - // Todo looks kind of bad make a better visible notification + // TODO: looks kind of bad make a better visible notification !isValidDay &&

{reason}

} {/* Hides the modal */} diff --git a/src/client/app/redux/api/conversionsApi.ts b/src/client/app/redux/api/conversionsApi.ts index c308101abb..134e5b15b2 100644 --- a/src/client/app/redux/api/conversionsApi.ts +++ b/src/client/app/redux/api/conversionsApi.ts @@ -29,7 +29,7 @@ export const conversionsApi = baseApi.injectEndpoints({ segmentNote: string; }>({ query: conversion => ({ - url: 'api/conversions/add', + url: 'api/conversions/addConversion', method: 'POST', body: conversion }), diff --git a/src/client/app/redux/api/daysApi.ts b/src/client/app/redux/api/daysApi.ts index e0b5f5b470..1d36036e11 100644 --- a/src/client/app/redux/api/daysApi.ts +++ b/src/client/app/redux/api/daysApi.ts @@ -15,7 +15,7 @@ export const daysApi = baseApi.injectEndpoints({ }), addDay: builder.mutation({ query: Day => ({ - url: 'api/days/add', + url: 'api/days/addDay', method: 'POST', body: Day }), diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index b08a868a9b..429a1dcc23 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -9,7 +9,6 @@ import { selectAllGroups } from '../../redux/api/groupsApi'; import { selectAllMeters, selectMeterById } from '../../redux/api/metersApi'; import { selectAdminPreferences } from '../../redux/slices/adminSlice'; import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; -import { ConversionData } from '../../types/redux/conversions'; import { MeterData, MeterTimeSortType } from '../../types/redux/meters'; import { DisableChecksType, UnitData, UnitType } from '../../types/redux/units'; import { Week } from '../../types/redux/weeks'; @@ -208,11 +207,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 @@ -223,6 +220,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')]; @@ -336,7 +337,7 @@ export const selectDefaultCreateConversionValues = createAppSelector( initialConversionNote: '', slope: 0, intercept: 0, - weeklyPattern: 'No Pattern' + weeklyPattern: -99 }; return defaultValues; } @@ -352,7 +353,7 @@ export const selectDefaultCreateDayValues = createAppSelector( intercept: 0, startHour:0, endHour: 24, - initialPatternNote: '' + initialSegmentNote: '' }; return defaultValues; } From 1e0b0338e353d8d246659611ee8b5a67348dbff9 Mon Sep 17 00:00:00 2001 From: PRINCESANCHEZ Date: Sat, 2 Aug 2025 21:34:45 -0700 Subject: [PATCH 043/170] Fix datetime handling, split logic, and styling in Edit Conversion modal Signed-off-by: PRINCESANCHEZ --- .../EditConversionModalComponent.tsx | 168 ++++++++---------- 1 file changed, 79 insertions(+), 89 deletions(-) diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index fb98c5879d..e301a157c1 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -21,7 +21,7 @@ import { TrueFalseType } from '../../types/items'; import { ConversionData } from '../../types/redux/conversions'; import { ConversionSegmentData } from '../../types/redux/conversionSegments'; import { UnitData, UnitType } from '../../types/redux/units'; -import { showErrorNotification } from 'utils/notifications'; +import { showErrorNotification } from '../../utils/notifications'; import { useTranslate } from '../../redux/componentHooks'; import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; @@ -37,6 +37,10 @@ interface EditConversionModalComponentProps { handleClose: () => void; } +const styleTableButton: React.CSSProperties = { + fontSize: '0.9rem' +}; + const PER_TABLE = 10; /** @@ -74,7 +78,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC const [actionDirection, setActionDirection] = useState<'earlier' | 'later' | null>(null); const [selectedSegment, setSelectedSegment] = useState(null); const [actionDatetime, setActionDatetime] = useState(''); - const [fieldErrors, setFieldErrors] = useState<{ segmentTimeError?: string; }>({}); + const [fieldErrors, setFieldErrors] = useState<{ segmentTimeError?: string; errorField?: string }>({}); const [currentPage, setCurrentPage] = React.useState(1); const [showAllSegments, setShowAllSegments] = React.useState(false); @@ -94,37 +98,27 @@ export default function EditConversionModalComponent(props: EditConversionModalC }; const handleNumberChange = (e: React.ChangeEvent) => { - setEditingSegment(prev => { - // Safety check: segment should always be set when editing, but added to prevent issues from future refactors - if (!prev) { - return prev; - } - return { - ...prev, - [e.target.name]: Number(e.target.value), // dynamically sets either slope or intercept - weekPatternsId: null // reset pattern if editing slope/intercept - }; - }); + setEditingSegment(prev => ({ + ...prev!, + [e.target.name]: Number(e.target.value), // dynamically sets either slope or intercept + weekPatternsId: null // reset pattern if editing slope/intercept + })); }; // Updates the pattern and resets slope/intercept if "No Pattern" is selected const handlePatternChange = (e: React.ChangeEvent) => { const selectedPattern = e.target.value; setEditingSegment(prev => { - if (!prev) { - return prev; - } - if (selectedPattern === 'No Pattern') { return { - ...prev, + ...prev!, weekPatternsId: null, slope: 0, intercept: 0 }; } else { return { - ...prev, + ...prev!, weekPatternsId: Number(selectedPattern) }; } @@ -134,13 +128,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Updates the note field for the currently edited segment // Separate handler needed since segment editing uses different state const handleSegmentNoteChange = (e: React.ChangeEvent) => { - setEditingSegment(prev => { - // Safety check: segment should always be set when editing, but added to prevent issues from future refactors - if (!prev) { - return prev; - } - return {...prev, note: e.target.value}; - }); + setEditingSegment(prev => ({ + ...prev!, + note: e.target.value + })); }; // Opens a modal showing the full segment note @@ -152,39 +143,25 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Handles and validates changes to datetime fields (start, end, or split) const handleDatetimeChange = (e: React.ChangeEvent) => { // Validate datetime format - const validFormat = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; - if (!validFormat.test(e.target.value.trim())) { - setFieldErrors(prev => ({ ...prev, + const validFormat = moment(e.target.value.trim(), 'YYYY-MM-DD HH:mm:ss', true).isValid(); + if (!validFormat) { + setFieldErrors({ segmentTimeError: intl.formatMessage( { id: 'conversion.error.datetime.invalid' }, - {field: e.target.name} - ) - })); - } else { + { field: e.target.name } + ), + errorField: e.target.name + }); + } else if (fieldErrors.errorField === e.target.name) { setFieldErrors({}); } - // Prevent editing of startTime or endTime if the current value is -infinity or infinity, - // since these represent unbounded segments and should not be user-editable - if ( - editingSegment && - (e.target.name === 'startTime' || e.target.name === 'endTime') && - (editingSegment[e.target.name] === '-infinity' || editingSegment[e.target.name] === 'infinity') - ) { - return; - } // Handle if editing a segment if (editingSegment && (e.target.name === 'startTime' || e.target.name === 'endTime')) { - setEditingSegment(prev => { - // Safety check: segment should always be set when editing, but added to prevent issues from future refactors - if (!prev) { - return prev; - } - return { - ...prev, - [e.target.name]: e.target.value - }; - }); + setEditingSegment(prev => ({ + ...prev!, + [e.target.name]: e.target.value + })); } // Handle if splitting segment if (e.target.name === 'splitDatetime') { @@ -195,7 +172,9 @@ export default function EditConversionModalComponent(props: EditConversionModalC // Splits a selected segment into two based on the chosen datetime and direction const handleSplitSegment = async () => { // Ensure required state is set before proceeding - if (!selectedSegment || !actionDirection || !actionDatetime) return; + if (!selectedSegment || !actionDirection || !actionDatetime) { + return; + } const splitTime = moment(actionDatetime); const isStartTimeValid = selectedSegment.startTime === '-infinity' || splitTime.isAfter(moment(selectedSegment.startTime)); @@ -230,11 +209,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC await addSegment({ ...segmentFields, startTime: selectedSegment.startTime, - endTime: actionDatetime, - slope: 0, - intercept: 0, - weekPatternsId: null, - note: '' + endTime: actionDatetime }); // Later segment keeps original values await addSegment({ @@ -253,11 +228,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC await addSegment({ ...segmentFields, startTime: actionDatetime, - endTime: selectedSegment.endTime, - slope: 0, - intercept: 0, - weekPatternsId: null, - note: '' + endTime: selectedSegment.endTime }); } setShowSplitSegmentModal(false); @@ -280,14 +251,20 @@ export default function EditConversionModalComponent(props: EditConversionModalC // to preserve continuous coverage with no time gaps or overlaps const handleDeleteSegment = async () => { // Ensure required state is set before proceeding - if (!selectedSegment || !actionDirection) return; + if (!selectedSegment || !actionDirection) { + return; + } try { // Sort segment by start time const sorted = [...segments].sort((a, b) => { const segmentAStart = a.startTime === '-infinity' ? null : moment(a.startTime); const segmentBStart = b.startTime === '-infinity' ? null : moment(b.startTime); - if (!segmentAStart) return -1; - if (!segmentBStart) return 1; + if (!segmentAStart) { + return -1; + } + if (!segmentBStart) { + return 1; + } return segmentAStart.isBefore(segmentBStart) ? -1 : 1; }); // Find the index of the segment selected for deletion @@ -570,10 +547,13 @@ export default function EditConversionModalComponent(props: EditConversionModalC // 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 = async () => { - // If the editing segment has no slope, no intercept, and no pattern applied, - // show a warning modal to confirm saving a "neutral" segment with no effect. - if (editingSegment && editingSegment.slope === 0 && editingSegment.intercept === 0 && editingSegment.weekPatternsId === null) { - console.warn('Saving a neutral segment'); + // Used to track whether all validation checks pass before allowing the save to proceed + let isSaveValid = true; + // Prevent saving if start time or end time is not in valid format + const isStartValid = moment(editingSegment?.startTime, 'YYYY-MM-DD HH:mm:ss', true).isValid() || editingSegment?.startTime === '-infinity'; + const isEndValid = moment(editingSegment?.endTime, 'YYYY-MM-DD HH:mm:ss', true).isValid() || editingSegment?.endTime === 'infinity'; + if (!isStartValid || !isEndValid) { + isSaveValid = false; } // If editing a segment's time range, ensure there are no gaps or overlaps with adjacent segments, // but only if the range is bounded (i.e., not -infinity or infinity). @@ -582,8 +562,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC const sortedSegments = [...segments].sort((a, b) => { const segmentAStart = a.startTime === '-infinity' ? null : moment(a.startTime); const segmentBStart = b.startTime === '-infinity' ? null : moment(b.startTime); - if (!segmentAStart) return -1; - if (!segmentBStart) return 1; + if (!segmentAStart) { + return -1; + } + if (!segmentBStart) { + return 1; + } return segmentAStart.isBefore(segmentBStart) ? -1 : 1; }); // Find the index of the segment being edited @@ -601,7 +585,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC { endTime: previous.endTime } ) })); - return; + isSaveValid = false; } // Check for gaps/overlaps with next segment if (next && !moment(editingSegment.endTime).isSame(next.startTime)) { @@ -611,9 +595,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC { startTime: next.startTime } ) })); - return; + isSaveValid = false; } } + if (!isSaveValid) { + return; + } // If a segment is being edited, send the updated data to the backend // and close the edit segment modal if the request succeeds. if (editingSegment) { @@ -696,28 +683,32 @@ export default function EditConversionModalComponent(props: EditConversionModalC - {fieldErrors.segmentTimeError} + + {fieldErrors.errorField === 'startTime' ? fieldErrors.segmentTimeError : null} + - {fieldErrors.segmentTimeError} + + {fieldErrors.errorField === 'endTime' ? fieldErrors.segmentTimeError : null} + @@ -771,7 +762,6 @@ export default function EditConversionModalComponent(props: EditConversionModalC
)} @@ -927,10 +917,10 @@ export default function EditConversionModalComponent(props: EditConversionModalC style={{ cursor: 'pointer' }} onClick={() => handleNoteModal(segment)} > - {(segment.note ?? '').length > 100 ? `${segment.note.slice(0, 100)} ...` : segment.note || ''} + {(segment.note ?? '').length > 30 ? `${segment.note.slice(0, 30)} ...` : segment.note || ''} - + +
+ )} +
+ ); +} + +const pageStyle: React.CSSProperties = { + padding: '4rem 0 2rem' +}; + +const holidayTitleStyle: React.CSSProperties = { + ...titleStyle, + color: '#111', + fontSize: '2.75rem', + fontWeight: 400, + margin: '0 0 1.35rem', + textAlign: 'center' +}; + +const holidayFilterStyle: React.CSSProperties = { + alignItems: 'flex-end', + display: 'flex', + flexWrap: 'wrap', + gap: '2rem', + justifyContent: 'center', + margin: '0 auto 2rem' +}; + +const holidayLimitGroupStyle: React.CSSProperties = { + margin: 0, + width: '21rem' +}; + +const holidayLimitLabelStyle: React.CSSProperties = { + display: 'block', + fontSize: '1.05rem', + fontWeight: 400, + margin: '0 0 0.25rem', + textAlign: 'center' +}; + +const holidayLimitInputStyle: React.CSSProperties = { + border: '1px solid #222', + borderRadius: 0, + fontSize: '1rem', + height: '2.05rem' +}; + +const tableWrapStyle: React.CSSProperties = { + margin: '0 auto', + maxWidth: '92%', + overflowX: 'auto' +}; + +const tableStyle: React.CSSProperties = { + border: '1px solid #222', + borderCollapse: 'collapse', + margin: 0, + minWidth: '720px', + tableLayout: 'fixed', + width: '100%' +}; + +const tableHeaderStyle: React.CSSProperties = { + border: '1px solid #222', + fontSize: '1.45rem', + fontWeight: 400, + height: '5.1rem', + textAlign: 'center', + verticalAlign: 'middle' +}; + +const tableCellStyle: React.CSSProperties = { + border: '1px solid #222', + fontSize: '1.35rem', + height: '2.45rem', + lineHeight: 1.15, + overflow: 'hidden', + padding: '0.35rem 0.75rem', + textAlign: 'center', + textOverflow: 'ellipsis', + verticalAlign: 'middle', + whiteSpace: 'nowrap' +}; + +const holidayNameColumnStyle: React.CSSProperties = { + width: '36%' +}; + +const holidayNoteColumnStyle: React.CSSProperties = { + width: '55%' +}; + +const holidaySelectColumnStyle: React.CSSProperties = { + width: '9%' +}; + +const checkboxLabelStyle: React.CSSProperties = { + alignItems: 'center', + cursor: 'pointer', + display: 'inline-flex', + justifyContent: 'center', + margin: 0, + minHeight: '1.35rem', + minWidth: '1.35rem', + position: 'relative' +}; + +const hiddenCheckboxStyle: React.CSSProperties = { + height: '1px', + opacity: 0, + position: 'absolute', + width: '1px' +}; + +const checkboxSquareStyle: React.CSSProperties = { + alignItems: 'center', + border: '1px solid #222', + color: '#111', + display: 'inline-flex', + fontSize: '1.05rem', + height: '1.25rem', + justifyContent: 'center', + lineHeight: 1, + width: '1.25rem' +}; + +const paginationStyle: React.CSSProperties = { + justifyContent: 'center', + margin: '14.5rem auto 2.25rem' +}; + +const actionContainerStyle: React.CSSProperties = { + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + gap: '1.25rem' +}; + +const outlineButtonStyle: React.CSSProperties = { + border: '1px solid #222', + borderRadius: 0, + color: '#111', + fontSize: '1rem', + height: '2.05rem', + minWidth: '7.2rem' +}; + +const wideOutlineButtonStyle: React.CSSProperties = { + ...outlineButtonStyle, + fontSize: '1.45rem', + height: '2.4rem', + maxWidth: '90%', + width: '37rem' +}; + +const emptyHolidayStyle: React.CSSProperties = { + margin: '2rem auto', + maxWidth: '40rem', + textAlign: 'center' +}; diff --git a/src/client/app/components/holiday/HolidayViewComponent.tsx b/src/client/app/components/holiday/HolidayViewComponent.tsx deleted file mode 100644 index e69de29bb2..0000000000 From 3c405e2aa00bc20084ef183912151446e631c99d Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 1 Jul 2026 15:47:34 -0700 Subject: [PATCH 133/170] Added modal for displaying full holiday note and changed styles to follow OED --- .../holiday/HolidayInstanceGroupComponent.tsx | 137 +++++++----------- 1 file changed, 54 insertions(+), 83 deletions(-) diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index fc74100773..140514bc5d 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { Alert, Button, FormFeedback, FormGroup, Input, Label, - Pagination, PaginationItem, PaginationLink, Table + Pagination, PaginationItem, PaginationLink, Table, Modal, ModalHeader, ModalBody } from 'reactstrap'; import { titleStyle } from '../../styles/modalStyle'; import { HolidayInstance } from 'types/redux/holiday'; @@ -15,7 +15,6 @@ const PER_PAGE = 20; interface HolidayInstanceGroupComponentProps { holidayInstances?: HolidayInstance[]; - initialSelectedHolidayInstanceIds?: number[]; handleUpdateHolidayLimit?: (holidayLimit: number) => void; handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[]) => void; } @@ -31,10 +30,14 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou const [displayLimit, setDisplayLimit] = React.useState(PER_PAGE); const [currentPage, setCurrentPage] = React.useState(1); const [showAllHolidays, setShowAllHolidays] = React.useState(false); - const [selectedHolidayInstanceIds, setSelectedHolidayInstanceIds] = React.useState( - props.initialSelectedHolidayInstanceIds ?? []); - + const [selectedHolidayInstanceIds, setSelectedHolidayInstanceIds] = React.useState([]); const holidayLimit = Number(holidayLimitText); + // Modal state for displaying full log message + const [modalOpen, setModalOpen] = React.useState(false); + // holiday name to display in the modal header + const [modelHeader, setModelHeader] = React.useState(''); + // Holiday note to display in the modal + const [modalHolidayNote, setModalHolidayNote] = React.useState(''); const holidayLimitInvalid = holidayLimitText.trim() === '' || !Number.isInteger(holidayLimit) || holidayLimit < 1; @@ -45,10 +48,9 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou const paginatedHolidayInstances = showAllHolidays ? displayedHolidayInstances : displayedHolidayInstances.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE); - - React.useEffect(() => { - setSelectedHolidayInstanceIds(props.initialSelectedHolidayInstanceIds ?? []); - }, [props.initialSelectedHolidayInstanceIds]); + const selectedHolidayInstanceIdsForGroup = holidayInstances + .filter(holidayInstance => selectedHolidayInstanceIds.includes(holidayInstance.id)) + .map(holidayInstance => holidayInstance.id); React.useEffect(() => { if (currentPage > totalPages) { @@ -67,6 +69,13 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou props.handleUpdateHolidayLimit?.(holidayLimit); }; + //open modal to show full holiday name and note + const handleHolidayNoteModal = (holidayName: string, holidayNote: string) => { + setModelHeader(holidayName); + setModalHolidayNote(holidayNote); + setModalOpen(true); + }; + const handleHolidayInstanceSelect = (holidayInstanceId: number) => { setSelectedHolidayInstanceIds(currentIds => currentIds.includes(holidayInstanceId) ? currentIds.filter(id => id !== holidayInstanceId) @@ -74,7 +83,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou }; const handleCreateHolidayInstanceGroup = () => { - props.handleCreateHolidayInstanceGroup?.(selectedHolidayInstanceIds); + props.handleCreateHolidayInstanceGroup?.(selectedHolidayInstanceIdsForGroup); }; return ( @@ -85,7 +94,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou
-
+ {/* Display holiday instances table */} {holidayInstances.length > 0 ? (
- - - - + {paginatedHolidayInstances.map(holidayInstance => { const selected = selectedHolidayInstanceIds.includes(holidayInstance.id); return ( - - - + {/* Base holiday is locked in by which card was clicked -> disabled */} + + + + + + + {/* Instance name — required */} + + + setDraftName(e.target.value)} + invalid={isNameInvalid} + /> + Name is required. + + + + + {/* Day pattern — required. Placeholder option mirrors the conversion + modal's hidden/disabled -999 sentinel option. */} + + + setDraftPatternId(Number(e.target.value))} + invalid={isPatternInvalid} + > + + {dayPatterns.map(pattern => ( + + ))} + + A day pattern is required. + + + {/* Note — optional */} + + + setDraftNote(e.target.value)} + /> + + + + + + + + + + ); +} + +/* Local style objects mirror the conversion page's imported titleStyle. + When you settle in, these can move to styles/modalStyle for reuse. */ + +const subtitleStyle: React.CSSProperties = { + fontSize: '1rem', + color: 'gray', + fontWeight: 500 +}; + +const clickableCardStyle: React.CSSProperties = { + cursor: 'pointer' +}; From f8889fd9e917c3fdd0ec52bb160a2ff8fba3ab2d Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 8 Jul 2026 11:53:58 -0700 Subject: [PATCH 136/170] Changed style to match OED Log Messages page more and removed unnecessary style css --- .../holiday/HolidayInstanceGroupComponent.tsx | 111 ++++-------------- 1 file changed, 21 insertions(+), 90 deletions(-) diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index 9018790691..053e14130b 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -89,7 +89,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou return (
-

+

@@ -117,8 +117,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou
@@ -188,15 +184,11 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou {!showAllHolidays && holidayInstances.length > 0 && ( - setCurrentPage(1)}> - {'<<'} - - - - setCurrentPage(currentPage - 1)}> - {'<'} - + setCurrentPage(1)} /> + + setCurrentPage(currentPage - 1)} /> + {Array.from({ length: totalPages }, (_, index) => ( setCurrentPage(index + 1)}> @@ -204,15 +196,11 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou ))} + - setCurrentPage(currentPage + 1)}> - {'>'} - - - - setCurrentPage(totalPages)}> - {'>>'} - + setCurrentPage(currentPage + 1)} /> + + setCurrentPage(totalPages)} /> )} @@ -221,13 +209,12 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou {holidayInstances.length > 0 && (
+ + +
+ {holidayInstance.name} - {holidayInstance.note} + handleHolidayNoteModal(holidayInstance.name, holidayInstance.note)} + > + {holidayInstance.note.length > 80 ? `${holidayInstance.note.slice(0, 80)}...` : holidayInstance.note} +
-
@@ -134,9 +218,11 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou - + {!viewingHolidayInstanceGroup && ( + + )} @@ -153,16 +239,18 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou > {holidayInstance.note.length > 80 ? `${holidayInstance.note.slice(0, 80)}...` : holidayInstance.note} - + {!viewingHolidayInstanceGroup && ( + + )} ); })} @@ -178,7 +266,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou )} {/* Pagination */} - {!showAllHolidays && holidayInstances.length > 0 && ( + {!showAllHolidays && tableHolidayInstances.length > 0 && ( setCurrentPage(1)} /> @@ -203,7 +291,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou )} {/* Action buttons for showing all holiday instances and creating holiday instance groups */} - {holidayInstances.length > 0 && ( + {tableHolidayInstances.length > 0 && (
- + {!viewingHolidayInstanceGroup && ( + + )}
)} {/* Modal for displaying full holiday note */} - setModalOpen(false)}> + setModalOpen(false)} centered> setModalOpen(false)}> {modelHeader} @@ -254,13 +344,21 @@ const pageStyle: React.CSSProperties = { const holidayFilterStyle: React.CSSProperties = { display: 'flex', justifyContent: 'center', + flexWrap: 'wrap', gap: '1.5%', alignItems: 'center', - margin: 'auto 25%', + margin: 'auto', + width: '92%', + maxWidth: '72rem', padding: '20px', border: '2px solid lightgrey' }; +const holidayInstanceGroupSelectStyle: React.CSSProperties = { + margin: 0, + width: '18rem' +}; + const holidayLimitGroupStyle: React.CSSProperties = { margin: 0, width: '21rem' From 35ad080dd4b7737ed326e66d944098eec2a38c36 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 15 Jul 2026 02:36:10 -0700 Subject: [PATCH 139/170] Created create holiday instance group modal component and gave it OED style. Edited Holiday instance group component to display the title and the create group button that displays the modal --- ...eateHolidayInstanceGroupModalComponent.tsx | 181 ++++ .../holiday/HolidayInstanceGroupComponent.tsx | 883 ++++++++++-------- 2 files changed, 697 insertions(+), 367 deletions(-) create mode 100644 src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx diff --git a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx new file mode 100644 index 0000000000..0cb1230a33 --- /dev/null +++ b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx @@ -0,0 +1,181 @@ +/* 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 { HolidayInstance } from '../../types/redux/holiday'; + +interface CreateHolidayInstanceGroupModalComponentProps { + holidayInstances: HolidayInstance[]; + onCreateHolidayInstanceGroup: (holidayInstanceIds: number[], note: string) => void; +} + +/** + * 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 [holidayInstanceIds, setHolidayInstanceIds] = useState([]); + const [note, setNote] = useState(''); + + const sortedHolidayInstances = React.useMemo( + () => [...props.holidayInstances].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [props.holidayInstances] + ); + const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ + label: holidayInstance.name, + value: holidayInstance.id + })); + const selectedHolidayInstanceOptions = holidayInstanceOptions.filter(option => + holidayInstanceIds.includes(option.value) + ); + + const resetState = () => { + setHolidayInstanceIds([]); + setNote(''); + }; + + const handleShow = () => setShowModal(true); + + const handleClose = () => { + setShowModal(false); + resetState(); + }; + + const handleSubmit = () => { + if (holidayInstanceIds.length === 0) { + return; + } + + props.onCreateHolidayInstanceGroup(holidayInstanceIds, note); + setShowModal(false); + resetState(); + }; + + const validHolidayInstanceGroup = holidayInstanceIds.length > 0; + + return ( + <> + + + + + + + + + +
+ {/* The holiday instances in this group */} + + + { + const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( + holidayInstance => holidayInstance.value + ); + setHolidayInstanceIds(updatedHolidayInstanceIds); + }} + /> + {!validHolidayInstanceGroup && ( + + + + )} + + + + + + + + + + + + + + + setNote(e.target.value)} + /> + + + + + + + + + + ); +} diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index 875a2bef42..3446a4c0c4 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -6,387 +6,536 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { Alert, Button, FormFeedback, FormGroup, Input, Label, - Pagination, PaginationItem, PaginationLink, Table, Modal, ModalHeader, ModalBody + Pagination, PaginationItem, PaginationLink, Table, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; import { titleStyle } from '../../styles/modalStyle'; import { HolidayInstance } from 'types/redux/holiday'; import { testHolidayInstances } from './holidayInstanceTestData'; // For testing purposes, remove this import in production - -const PER_PAGE = 20; - -interface HolidayInstanceGroup { - id: number; - holidayInstanceIds: number[]; -} - +import CreateHolidayInstanceGroupModalComponent from './CreateHolidayInstanceGroupModalComponent'; + + +// interface HolidayInstanceGroup { +// id: number; +// holidayInstanceIds: number[]; +// } + +// interface HolidayInstanceGroupComponentProps { +// holidayInstances?: HolidayInstance[]; +// holidayInstanceGroups?: HolidayInstanceGroup[]; +// handleUpdateHolidayLimit?: (holidayLimit: number) => void; +// handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[]) => void; +// handleUpdateHolidayInstanceGroup?: (holidayInstanceGroupId: number, holidayInstanceIds: number[]) => void; +// handleDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; +// } + +// /** +// * Defines the holiday instance group selection page. +// * @param props Holiday instance data and optional action handlers +// * @returns Holiday instance group page element +// */ +// export default function HolidayInstanceGroupComponent(props: HolidayInstanceGroupComponentProps) { +// const holidayInstances = props.holidayInstances ?? testHolidayInstances; +// const [holidayLimitText, setHolidayLimitText] = React.useState(PER_PAGE.toString()); +// const [displayLimit, setDisplayLimit] = React.useState(PER_PAGE); +// const [currentPage, setCurrentPage] = React.useState(1); +// const [showAllHolidays, setShowAllHolidays] = React.useState(false); +// const [selectedHolidayInstanceIds, setSelectedHolidayInstanceIds] = React.useState([]); +// const [createdHolidayInstanceGroups, setCreatedHolidayInstanceGroups] = React.useState([]); +// const [selectedHolidayInstanceGroupId, setSelectedHolidayInstanceGroupId] = React.useState(''); +// const [selectedHolidayInstanceGroupHolidayIds, setSelectedHolidayInstanceGroupHolidayIds] = React.useState([]); +// const [deleteHolidayInstanceGroupModalOpen, setDeleteHolidayInstanceGroupModalOpen] = React.useState(false); +// const holidayLimit = Number(holidayLimitText); +// // Modal state for displaying full log message +// const [modalOpen, setModalOpen] = React.useState(false); +// // holiday name to display in the modal header +// const [modelHeader, setModelHeader] = React.useState(''); +// // Holiday note to display in the modal +// const [modalHolidayNote, setModalHolidayNote] = React.useState(''); +// const holidayLimitInvalid = holidayLimitText.trim() === '' +// || !Number.isInteger(holidayLimit) +// || holidayLimit < 1; +// const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; +// const selectedHolidayInstanceGroup = holidayInstanceGroups.find(holidayInstanceGroup => +// holidayInstanceGroup.id.toString() === selectedHolidayInstanceGroupId); +// const viewingHolidayInstanceGroup = selectedHolidayInstanceGroup !== undefined; +// const tableHolidayInstances = viewingHolidayInstanceGroup +// ? holidayInstances.filter(holidayInstance => +// selectedHolidayInstanceGroupHolidayIds.includes(holidayInstance.id)) +// : holidayInstances; +// const totalPages = Math.max(1, Math.ceil(tableHolidayInstances.length / displayLimit)); +// const paginatedHolidayInstances = showAllHolidays +// ? tableHolidayInstances +// : tableHolidayInstances.slice((currentPage - 1) * displayLimit, currentPage * displayLimit); +// const selectedHolidayInstanceIdsForGroup = holidayInstances +// .filter(holidayInstance => selectedHolidayInstanceIds.includes(holidayInstance.id)) +// .map(holidayInstance => holidayInstance.id); + +// React.useEffect(() => { +// if (currentPage > totalPages) { +// setCurrentPage(totalPages); +// } +// }, [currentPage, totalPages]); + +// const handleUpdateHolidayLimit = () => { +// if (holidayLimitInvalid) { +// return; +// } + +// setDisplayLimit(holidayLimit); +// setCurrentPage(1); +// setShowAllHolidays(false); +// props.handleUpdateHolidayLimit?.(holidayLimit); +// }; + +// const handleHolidayInstanceGroupChange = (holidayInstanceGroupId: string) => { +// const nextHolidayInstanceGroup = holidayInstanceGroups.find(holidayInstanceGroup => +// holidayInstanceGroup.id.toString() === holidayInstanceGroupId); + +// setSelectedHolidayInstanceGroupId(holidayInstanceGroupId); +// setSelectedHolidayInstanceGroupHolidayIds(nextHolidayInstanceGroup?.holidayInstanceIds ?? []); +// setSelectedHolidayInstanceIds([]); +// setCurrentPage(1); +// setShowAllHolidays(false); +// }; + +// const handleCreateNewHolidayInstanceGroup = () => { +// setSelectedHolidayInstanceGroupId(''); +// setSelectedHolidayInstanceGroupHolidayIds([]); +// setSelectedHolidayInstanceIds([]); +// setCurrentPage(1); +// setShowAllHolidays(false); +// }; + +// //open modal to show full holiday name and note +// const handleHolidayNoteModal = (holidayName: string, holidayNote: string) => { +// setModelHeader(holidayName); +// setModalHolidayNote(holidayNote); +// setModalOpen(true); +// }; + +// const handleHolidayInstanceSelect = (holidayInstanceId: number) => { +// setSelectedHolidayInstanceIds(currentIds => currentIds.includes(holidayInstanceId) +// ? currentIds.filter(id => id !== holidayInstanceId) +// : [...currentIds, holidayInstanceId]); +// }; + +// const handleRemoveHolidayInstanceFromGroup = (holidayInstanceId: number) => { +// setSelectedHolidayInstanceGroupHolidayIds(currentIds => +// currentIds.filter(id => id !== holidayInstanceId)); +// }; + +// const handleSaveHolidayInstanceGroupChanges = () => { +// if (selectedHolidayInstanceGroup === undefined) { +// return; +// } + +// props.handleUpdateHolidayInstanceGroup?.( +// selectedHolidayInstanceGroup.id, +// selectedHolidayInstanceGroupHolidayIds +// ); + +// if (props.holidayInstanceGroups === undefined) { +// setCreatedHolidayInstanceGroups(currentGroups => currentGroups.map(group => +// group.id === selectedHolidayInstanceGroup.id +// ? { ...group, holidayInstanceIds: selectedHolidayInstanceGroupHolidayIds } +// : group)); +// } +// }; + +// const handleDeleteHolidayInstanceGroup = () => { +// if (selectedHolidayInstanceGroup === undefined) { +// return; +// } + +// props.handleDeleteHolidayInstanceGroup?.(selectedHolidayInstanceGroup.id); + +// if (props.holidayInstanceGroups === undefined) { +// setCreatedHolidayInstanceGroups(currentGroups => +// currentGroups.filter(group => group.id !== selectedHolidayInstanceGroup.id)); +// } + +// setDeleteHolidayInstanceGroupModalOpen(false); +// setSelectedHolidayInstanceGroupId(''); +// setSelectedHolidayInstanceGroupHolidayIds([]); +// setCurrentPage(1); +// setShowAllHolidays(false); +// }; + +// const handleCreateHolidayInstanceGroup = () => { +// if (selectedHolidayInstanceIdsForGroup.length === 0) { +// return; +// } + +// props.handleCreateHolidayInstanceGroup?.(selectedHolidayInstanceIdsForGroup); + +// if (props.holidayInstanceGroups === undefined) { +// setCreatedHolidayInstanceGroups(currentGroups => { +// const nextHolidayInstanceGroupId = Math.max(0, ...currentGroups.map(group => group.id)) + 1; + +// return [ +// ...currentGroups, +// { +// id: nextHolidayInstanceGroupId, +// holidayInstanceIds: selectedHolidayInstanceIdsForGroup +// } +// ]; +// }); +// } + +// setSelectedHolidayInstanceIds([]); +// }; + +// return ( +//
+//

+// +//

+ +//
+// +// +// handleHolidayInstanceGroupChange(e.target.value)} +// > +// +// {holidayInstanceGroups.map(holidayInstanceGroup => ( +// +// ))} +// +// +// +// +// +// setHolidayLimitText(e.target.value)} +// /> +// +// +// +// +// +//
+ +// {/* Display holiday instances table */} +// {tableHolidayInstances.length > 0 ? ( +//
+//
- - + +
- - + +
+// +// +// +// +// +// +// +// +// {paginatedHolidayInstances.map(holidayInstance => { +// const selected = selectedHolidayInstanceIds.includes(holidayInstance.id); + +// return ( +// +// +// +// {viewingHolidayInstanceGroup ? ( +// +// ) : ( +// +// )} +// +// ); +// })} +// +//
+// +// +// +// +// {viewingHolidayInstanceGroup ? ( +// +// ) : ( +// +// )} +//
+// {holidayInstance.name} +// handleHolidayNoteModal(holidayInstance.name, holidayInstance.note)} +// > +// {holidayInstance.note.length > 80 ? `${holidayInstance.note.slice(0, 80)}...` : holidayInstance.note} +// +// +// +// +//
+//
+// ) : ( +// +// +// +// )} + +// {/* Pagination */} +// {!showAllHolidays && tableHolidayInstances.length > 0 && ( +// +// +// setCurrentPage(1)} /> +// +// setCurrentPage(currentPage - 1)} /> +// + +// {Array.from({ length: totalPages }, (_, index) => ( +// +// setCurrentPage(index + 1)}> +// {index + 1} +// +// +// ))} + +// +// setCurrentPage(currentPage + 1)} /> +// +// setCurrentPage(totalPages)} /> +// +// +// )} + +// {/* Action buttons for showing all holiday instances and creating holiday instance groups */} +// {(tableHolidayInstances.length > 0 || viewingHolidayInstanceGroup) && ( +//
+// {tableHolidayInstances.length > 0 && ( +// +// )} +// {!viewingHolidayInstanceGroup && ( +// +// )} +// {viewingHolidayInstanceGroup && ( +//
+// +// +//
+// )} +//
+// )} + +// {/* Modal for displaying full holiday note */} +// setModalOpen(false)} centered> +// setModalOpen(false)}> +// {modelHeader} +// +// +// {modalHolidayNote} +// +// + +// setDeleteHolidayInstanceGroupModalOpen(false)} +// centered> +// setDeleteHolidayInstanceGroupModalOpen(false)}> +// +// +// +// +// +// +// +// +// +// +//
+// ); +// } + +// const pageStyle: React.CSSProperties = { +// padding: '4rem 0 2rem', +// width: '100%' +// }; + +// const holidayFilterStyle: React.CSSProperties = { +// display: 'flex', +// justifyContent: 'center', +// flexWrap: 'wrap', +// gap: '1.5%', +// alignItems: 'center', +// margin: 'auto', +// width: '92%', +// maxWidth: '72rem', +// padding: '20px', +// border: '2px solid lightgrey' +// }; + +// const holidayInstanceGroupSelectStyle: React.CSSProperties = { +// margin: 0, +// width: '18rem' +// }; + +// const holidayLimitGroupStyle: React.CSSProperties = { +// margin: 0, +// width: '21rem' +// }; + +// const tableWrapStyle: React.CSSProperties = { +// margin: '0 auto', +// width: '92%' +// }; + +// const tableStyle: React.CSSProperties = { +// width: '90%', +// margin: '1% auto' +// }; + +// const bodyStyle: React.CSSProperties = { +// textAlign: 'left' +// }; + +// const actionContainerStyle: React.CSSProperties = { +// alignItems: 'center', +// display: 'flex', +// flexDirection: 'column', +// gap: '1.25rem' +// }; + +// const holidayInstanceGroupActionStyle: React.CSSProperties = { +// display: 'flex', +// gap: '1rem', +// justifyContent: 'center', +// margin: '0% 40% 1%' +// }; + +// const emptyHolidayStyle: React.CSSProperties = { +// margin: '2rem auto', +// maxWidth: '40rem', +// textAlign: 'center' +// }; interface HolidayInstanceGroupComponentProps { holidayInstances?: HolidayInstance[]; - holidayInstanceGroups?: HolidayInstanceGroup[]; - handleUpdateHolidayLimit?: (holidayLimit: number) => void; - handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[]) => void; + handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[], note?: string) => void; } /** - * Defines the holiday instance group selection page. - * @param props Holiday instance data and optional action handlers - * @returns Holiday instance group page element + * Defines the holiday instance group page. + * @param props Available holiday instances and the create handler. + * @returns Holiday instance group page element. */ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGroupComponentProps) { - const holidayInstances = props.holidayInstances ?? testHolidayInstances; - const [holidayLimitText, setHolidayLimitText] = React.useState(PER_PAGE.toString()); - const [displayLimit, setDisplayLimit] = React.useState(PER_PAGE); - const [currentPage, setCurrentPage] = React.useState(1); - const [showAllHolidays, setShowAllHolidays] = React.useState(false); - const [selectedHolidayInstanceIds, setSelectedHolidayInstanceIds] = React.useState([]); - const [createdHolidayInstanceGroups, setCreatedHolidayInstanceGroups] = React.useState([]); - const [selectedHolidayInstanceGroupId, setSelectedHolidayInstanceGroupId] = React.useState(''); - const holidayLimit = Number(holidayLimitText); - // Modal state for displaying full log message - const [modalOpen, setModalOpen] = React.useState(false); - // holiday name to display in the modal header - const [modelHeader, setModelHeader] = React.useState(''); - // Holiday note to display in the modal - const [modalHolidayNote, setModalHolidayNote] = React.useState(''); - const holidayLimitInvalid = holidayLimitText.trim() === '' - || !Number.isInteger(holidayLimit) - || holidayLimit < 1; - const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; - const selectedHolidayInstanceGroup = holidayInstanceGroups.find(holidayInstanceGroup => - holidayInstanceGroup.id.toString() === selectedHolidayInstanceGroupId); - const viewingHolidayInstanceGroup = selectedHolidayInstanceGroup !== undefined; - const tableHolidayInstances = viewingHolidayInstanceGroup - ? holidayInstances.filter(holidayInstance => - selectedHolidayInstanceGroup.holidayInstanceIds.includes(holidayInstance.id)) - : holidayInstances; - const totalPages = Math.max(1, Math.ceil(tableHolidayInstances.length / displayLimit)); - const paginatedHolidayInstances = showAllHolidays - ? tableHolidayInstances - : tableHolidayInstances.slice((currentPage - 1) * displayLimit, currentPage * displayLimit); - const selectedHolidayInstanceIdsForGroup = holidayInstances - .filter(holidayInstance => selectedHolidayInstanceIds.includes(holidayInstance.id)) - .map(holidayInstance => holidayInstance.id); - - React.useEffect(() => { - if (currentPage > totalPages) { - setCurrentPage(totalPages); - } - }, [currentPage, totalPages]); - - const handleUpdateHolidayLimit = () => { - if (holidayLimitInvalid) { - return; - } - - setDisplayLimit(holidayLimit); - setCurrentPage(1); - setShowAllHolidays(false); - props.handleUpdateHolidayLimit?.(holidayLimit); - }; - - const handleHolidayInstanceGroupChange = (holidayInstanceGroupId: string) => { - setSelectedHolidayInstanceGroupId(holidayInstanceGroupId); - setSelectedHolidayInstanceIds([]); - setCurrentPage(1); - setShowAllHolidays(false); - }; - - const handleCreateNewHolidayInstanceGroup = () => { - setSelectedHolidayInstanceGroupId(''); - setSelectedHolidayInstanceIds([]); - setCurrentPage(1); - setShowAllHolidays(false); - }; - - //open modal to show full holiday name and note - const handleHolidayNoteModal = (holidayName: string, holidayNote: string) => { - setModelHeader(holidayName); - setModalHolidayNote(holidayNote); - setModalOpen(true); - }; - - const handleHolidayInstanceSelect = (holidayInstanceId: number) => { - setSelectedHolidayInstanceIds(currentIds => currentIds.includes(holidayInstanceId) - ? currentIds.filter(id => id !== holidayInstanceId) - : [...currentIds, holidayInstanceId]); - }; - - const handleCreateHolidayInstanceGroup = () => { - if (selectedHolidayInstanceIdsForGroup.length === 0) { - return; - } - - props.handleCreateHolidayInstanceGroup?.(selectedHolidayInstanceIdsForGroup); - - if (props.holidayInstanceGroups === undefined) { - setCreatedHolidayInstanceGroups(currentGroups => { - const nextHolidayInstanceGroupId = Math.max(0, ...currentGroups.map(group => group.id)) + 1; - - return [ - ...currentGroups, - { - id: nextHolidayInstanceGroupId, - holidayInstanceIds: selectedHolidayInstanceIdsForGroup - } - ]; - }); - } - - setSelectedHolidayInstanceIds([]); - }; - return ( -
-

- -

- -
- - - handleHolidayInstanceGroupChange(e.target.value)} - > - - {holidayInstanceGroups.map(holidayInstanceGroup => ( - - ))} - - - - - - setHolidayLimitText(e.target.value)} + id='holiday.instance.group' + defaultMessage='Holiday Instance Group' + /> + +
+ + props.handleCreateHolidayInstanceGroup?.(holidayInstanceIds, note)} /> - - - - - -
- - {/* Display holiday instances table */} - {tableHolidayInstances.length > 0 ? ( -
- - - - - - {!viewingHolidayInstanceGroup && ( - - )} - - - - {paginatedHolidayInstances.map(holidayInstance => { - const selected = selectedHolidayInstanceIds.includes(holidayInstance.id); - - return ( - - - - {!viewingHolidayInstanceGroup && ( - - )} - - ); - })} - -
- - - - - -
- {holidayInstance.name} - handleHolidayNoteModal(holidayInstance.name, holidayInstance.note)} - > - {holidayInstance.note.length > 80 ? `${holidayInstance.note.slice(0, 80)}...` : holidayInstance.note} - - -
-
- ) : ( - - - - )} - - {/* Pagination */} - {!showAllHolidays && tableHolidayInstances.length > 0 && ( - - - setCurrentPage(1)} /> - - setCurrentPage(currentPage - 1)} /> - - - {Array.from({ length: totalPages }, (_, index) => ( - - setCurrentPage(index + 1)}> - {index + 1} - - - ))} - - - setCurrentPage(currentPage + 1)} /> - - setCurrentPage(totalPages)} /> - - - )} - - {/* Action buttons for showing all holiday instances and creating holiday instance groups */} - {tableHolidayInstances.length > 0 && ( -
- - {!viewingHolidayInstanceGroup && ( - - )}
- )} - - {/* Modal for displaying full holiday note */} - setModalOpen(false)} centered> - setModalOpen(false)}> - {modelHeader} - - - {modalHolidayNote} - - +
); -} - -const pageStyle: React.CSSProperties = { - padding: '4rem 0 2rem', - width: '100%' -}; - -const holidayFilterStyle: React.CSSProperties = { - display: 'flex', - justifyContent: 'center', - flexWrap: 'wrap', - gap: '1.5%', - alignItems: 'center', - margin: 'auto', - width: '92%', - maxWidth: '72rem', - padding: '20px', - border: '2px solid lightgrey' -}; - -const holidayInstanceGroupSelectStyle: React.CSSProperties = { - margin: 0, - width: '18rem' -}; - -const holidayLimitGroupStyle: React.CSSProperties = { - margin: 0, - width: '21rem' -}; - -const tableWrapStyle: React.CSSProperties = { - margin: '0 auto', - width: '92%' -}; - -const tableStyle: React.CSSProperties = { - width: '90%', - margin: '1% auto' -}; - -const bodyStyle: React.CSSProperties = { - textAlign: 'left' -}; - -const actionContainerStyle: React.CSSProperties = { - alignItems: 'center', - display: 'flex', - flexDirection: 'column', - gap: '1.25rem' -}; - -const emptyHolidayStyle: React.CSSProperties = { - margin: '2rem auto', - maxWidth: '40rem', - textAlign: 'center' -}; +} \ No newline at end of file From e8f2d0efddf5d7a2bc394f428297b757ef1cdb5c Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 15 Jul 2026 13:31:39 -0700 Subject: [PATCH 140/170] added holidayInstanceGroupViewComponent and edit Holiday Instance Group Modal component --- ...EditHolidayInstanceGroupModalComponent.tsx | 230 ++++++++++++++++++ .../HolidayInstanceGroupViewComponent.jsx | 110 +++++++++ 2 files changed, 340 insertions(+) create mode 100644 src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx create mode 100644 src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx diff --git a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx new file mode 100644 index 0000000000..a1fb0c727d --- /dev/null +++ b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx @@ -0,0 +1,230 @@ +/* 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 { HolidayInstance } from '../../types/redux/holiday'; + +export interface HolidayInstanceGroupData { + id: number; + holidayInstanceIds: number[]; + note: string; +} + +interface EditHolidayInstanceGroupModalComponentProps { + show: boolean; + holidayInstanceGroup: HolidayInstanceGroupData; + holidayInstances: HolidayInstance[]; + handleShow: () => void; + handleClose: () => void; + onEditHolidayInstanceGroup: ( + holidayInstanceGroupId: number, + holidayInstanceIds: number[], + note: string + ) => void; + onDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; +} + +/** + * 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 [holidayInstanceIds, setHolidayInstanceIds] = useState( + props.holidayInstanceGroup.holidayInstanceIds + ); + const [note, setNote] = useState(props.holidayInstanceGroup.note); + const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false); + + const sortedHolidayInstances = React.useMemo( + () => [...props.holidayInstances].sort((first, second) => + first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), + [props.holidayInstances] + ); + const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ + label: holidayInstance.name, + value: holidayInstance.id + })); + const selectedHolidayInstanceOptions = holidayInstanceOptions.filter(option => + holidayInstanceIds.includes(option.value) + ); + const validHolidayInstanceGroup = holidayInstanceIds.length > 0; + + const resetState = React.useCallback(() => { + setHolidayInstanceIds(props.holidayInstanceGroup.holidayInstanceIds); + setNote(props.holidayInstanceGroup.note); + }, [props.holidayInstanceGroup]); + + useEffect(() => { + if (props.show) { + resetState(); + } + }, [props.show, resetState]); + + const handleClose = () => { + props.handleClose(); + resetState(); + }; + + const handleSaveChanges = () => { + if (!validHolidayInstanceGroup) { + return; + } + + props.handleClose(); + props.onEditHolidayInstanceGroup( + props.holidayInstanceGroup.id, + holidayInstanceIds, + note + ); + }; + + const handleDeleteConfirmationModalOpen = () => { + props.handleClose(); + setShowDeleteConfirmationModal(true); + }; + + const handleDeleteConfirmationModalClose = () => { + setShowDeleteConfirmationModal(false); + props.handleShow(); + }; + + const handleDeleteHolidayInstanceGroup = () => { + setShowDeleteConfirmationModal(false); + props.onDeleteHolidayInstanceGroup?.(props.holidayInstanceGroup.id); + }; + + return ( + <> + + + + + + + + + + + {/* The holiday instances in this group */} + + + { + const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( + holidayInstance => holidayInstance.value + ); + setHolidayInstanceIds(updatedHolidayInstanceIds); + }} + /> + {!validHolidayInstanceGroup && ( + + + + )} + + + + + + + + + + + + + + + setNote(e.target.value)} + /> + + + + + {props.onDeleteHolidayInstanceGroup && ( + + )} + + + + + + ); +} diff --git a/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx new file mode 100644 index 0000000000..cb4107723b --- /dev/null +++ b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx @@ -0,0 +1,110 @@ +/* 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, holidayInstanceIds: number[], note: string}} props.holidayInstanceGroup Group data. + * @param {Array<{id: number, name: string}>} props.holidayInstances Available holiday instances. + * @param {(id: number, 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); + }; + + const selectedHolidayInstances = props.holidayInstanceGroup.holidayInstanceIds + .map(holidayInstanceId => props.holidayInstances.find( + holidayInstance => holidayInstance.id === holidayInstanceId + )) + .filter(holidayInstance => holidayInstance !== undefined); + + const note = props.holidayInstanceGroup.note ?? ''; + const displayedNote = note.length > 30 ? `${note.slice(0, 29)}...` : note; + + return ( +
+
+ +
+ +
+ + + + {selectedHolidayInstances.length > 0 ? ( +
    + {selectedHolidayInstances.map(holidayInstance => ( +
  • {holidayInstance.name}
  • + ))} +
+ ) : ( + None + )} +
+ +
+ + + {' '} + +
+ +
+ {' '} + {displayedNote} +
+ +
+ + +
+
+ ); +} + +const holidayInstanceListStyle = { + margin: '0.5rem 0 0', + maxHeight: '8rem', + overflowY: 'auto', + paddingLeft: '1.25rem' +}; From cbd956957265c86998f435e52894ec1404c81663 Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Wed, 15 Jul 2026 13:42:14 -0700 Subject: [PATCH 141/170] Frontend of Holiday Instance Page should be finished --- .../holiday-instances/HolidayInstancePage.tsx | 603 ++++++++++++++---- 1 file changed, 464 insertions(+), 139 deletions(-) diff --git a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx index fd275792e4..8657b15f95 100644 --- a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx +++ b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx @@ -3,7 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import Select from 'react-select'; import { Button, Col, Container, FormFeedback, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader, Row @@ -15,13 +16,40 @@ import { titleStyle } from '../../styles/modalStyle'; * DATA SEAM (temporary — replace with real RTK Query hooks when the * backend routes / API slices exist) * - * When the backend is ready, delete the two mock hooks below and swap in: - * - holidaysApi.useGetHolidaysQuery() -> raw holidays (Inbox) - * - dayPatternsApi.useGetDayPatternsQuery() -> day pattern options - * - holidayInstancesApi.useAddHolidayInstanceMutation() -> save (Outbox) + * When the backend is ready, delete the mock hooks below and swap in: + * - holidaysApi.useGetHolidaysQuery() -> base holidays for the create dropdown + * - dayPatternsApi.useGetDayPatternsQuery() -> day pattern options + * - holidayInstancesApi.useGetHolidayInstancesQuery() -> existing instances (the cards) + * - holidayInstancesApi.useAddHolidayInstanceMutation() -> create (POST, no id) + * - holidayInstancesApi.useEditHolidayInstanceMutation()-> edit (PATCH, id required) + * - holidayInstancesApi.useDeleteHolidayInstanceMutation() * - * The component body below only touches the *return values* of these hooks, - * so wiring the real API should be a change confined to this block. + * The instances store AND its mutations live inside this seam + * (useHolidayInstancesMock), so the component body only consumes the hook + * return values — the RTK Query swap really is confined to this block. + * + * REGION (meeting 4 / 7-2): Steve wants a region selector that auto-populates + * the base-holiday dropdown from the selected region's holidays. That is + * blocked on Rose's region API + the team's single-vs-multi-region decision, + * so it is stubbed below (see `REGION STUB`). For now the base-holiday + * dropdown is fed by the full mock holiday list. + * + * UNIQUENESS (meeting 3, clarified 7/7): multiple instances of the SAME base + * holiday (at different rates) are allowed here on purpose — no uniqueness + * check belongs on this page. The "same holiday twice" guard lives on Jason's + * group page. + * + * TERMINOLOGY (meeting 4): user-facing text says "Holiday Rate" (Steve's + * suggestion — "instance" is too technical for admins). Internal names keep + * "instance" to match the holiday_instance table and route names. Wording + * mirrors the Units page: "Holiday Rates" / "Create a Holiday Rate" / + * "Details/Edit Holiday Rate". + * + * I18N (deferred on purpose): strings are hardcoded English for now so the + * page works without touching translations/data.ts. Before merging upstream, + * swap the STRINGS object values for translate('...') calls — every + * user-facing string routes through STRINGS below, so the refactor is + * confined to that one block. * ------------------------------------------------------------------------- */ @@ -36,15 +64,31 @@ interface DayPatternOption { name: string; } -interface ConfiguredInstance { +/* + * Create vs Edit types (meeting 4 / 7-2): the base payload carries NO id. + * Create sends exactly this. Edit extends it with the id. This keeps `create` + * from ever sending an id, matching OED's tightened route parameter checks. + */ +interface HolidayInstanceData { instanceName: string; baseHolidayId: number; - baseHolidayName: string; patternId: number; - patternName: string; note: string; } +interface HolidayInstance extends HolidayInstanceData { + id: number; +} + +/* + * Edit payload. The base holiday is immutable after create (meeting-4 + * decision: changing region/base holiday invalidates the instance — delete + * and recreate instead), so the PATCH payload omits `baseHolidayId` entirely + * rather than resending it. Matches OED's tightened route parameter checking: + * don't send fields the route won't accept. + */ +type HolidayInstancePatch = Omit & { id: number }; + // Mock: stands in for holidaysApi.useGetHolidaysQuery() // Returns the raw holidays Rose's page will eventually save to the `holidays` table. const useHolidaysMock = () => { @@ -55,7 +99,9 @@ const useHolidaysMock = () => { const timer = setTimeout(() => { setData([ { id: 1, name: "New Year's Day 2026", date: 'Jan 1, 2026' }, - { id: 2, name: 'Thanksgiving 2026', date: 'Nov 26, 2026' } + { id: 2, name: 'Thanksgiving 2026', date: 'Nov 26, 2026' }, + { id: 3, name: 'Independence Day 2026', date: 'Jul 4, 2026' }, + { id: 4, name: 'Labor Day 2026', date: 'Sep 7, 2026' } ]); setIsFetching(false); }, 500); @@ -67,20 +113,88 @@ const useHolidaysMock = () => { // Mock: stands in for dayPatternsApi.useGetDayPatternsQuery() // Day patterns already exist in the schema and do NOT depend on Rose's page. -const useDayPatternsMock = (): { data: DayPatternOption[] } => { - return { - data: [ - { id: 101, name: 'Winter Rate' }, - { id: 102, name: 'Summer Rate' }, - { id: 103, name: 'Standard Rate' } - ] +// Exposes isFetching like the real query will, so the page's loading gate +// covers both fetches. +const useDayPatternsMock = () => { + const [data, setData] = useState([]); + const [isFetching, setIsFetching] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setData([ + { id: 101, name: 'Winter Rate' }, + { id: 102, name: 'Summer Rate' }, + { id: 103, name: 'Standard Rate' } + ]); + setIsFetching(false); + }, 300); + return () => clearTimeout(timer); + }, []); + + return { data, isFetching }; +}; + +// Mock: stands in for useGetHolidayInstancesQuery() + the three mutations. +// In production each mutation POSTs/PATCHes/DELETEs, then INVALIDATES the +// instances cache tag so the list refetches from the server — global state is +// never written from the local copy (Steve's state model, meeting 4). +const useHolidayInstancesMock = () => { + const [data, setData] = useState([]); + + // PRODUCTION: useAddHolidayInstanceMutation()(draft) — payload has NO id. + const addInstance = (draft: HolidayInstanceData) => { + // Date.now() is a mock-only id; the real id comes back from the server. + setData(prev => [...prev, { ...draft, id: Date.now() }]); + }; + + // PRODUCTION: useEditHolidayInstanceMutation()(patch). + const editInstance = (patch: HolidayInstancePatch) => { + setData(prev => prev.map(i => (i.id === patch.id ? { ...i, ...patch } : i))); + }; + + // PRODUCTION: useDeleteHolidayInstanceMutation()(id). + const deleteInstance = (id: number) => { + setData(prev => prev.filter(i => i.id !== id)); }; + + return { data, addInstance, editInstance, deleteInstance }; }; /* End data seam */ -// Sentinel value for the "nothing selected yet" state of the pattern dropdown, -// mirroring the -999 / -99 placeholder convention used in the conversion modals. +/* + * All user-facing text, in one place. TODO(i18n): replace each value with the + * matching translate('...') call and add the keys to translations/data.ts — + * nothing outside this object needs to change. + */ +const STRINGS = { + pageTitle: 'Holiday Rates', + createButton: 'Create a Holiday Rate', + detailsButton: 'Details/Edit Holiday Rate', + noRates: 'No holiday rates have been created yet.', + loading: 'Loading holidays...', + baseHoliday: 'Base Holiday:', + baseHolidaySelect: 'Select or search for a holiday...', + baseHolidayRequired: 'Base holiday is required', + baseHolidayUnknown: '(unknown holiday)', + rateName: 'Holiday Rate Name:', + nameRequired: 'Name is required', + dayPattern: 'Day Pattern:', + dayPatternSelect: 'Select a day pattern', + dayPatternRequired: 'Day pattern is required', + note: 'Note:', + deleteButton: 'Delete Holiday Rate', + discardButton: 'Discard Changes', + createSaveButton: 'Create Holiday Rate', + saveButton: 'Save Holiday Rate', + cancel: 'Cancel', + unsavedWarning: 'You have unsaved changes. Are you sure you want to discard them?', + deleteWarning: 'Are you sure you want to delete this holiday rate? This cannot be undone.' +}; + +// Sentinel values for "nothing selected yet" dropdown state, mirroring the +// -999 placeholder convention used in the conversion modals. +const NO_HOLIDAY = -999; const NO_PATTERN = -999; // Cards truncate long names at 30 chars, matching the conversion note/log convention. @@ -88,80 +202,210 @@ const MAX_NAME_LENGTH = 30; const truncate = (text: string) => text.length > MAX_NAME_LENGTH ? `${text.slice(0, MAX_NAME_LENGTH)} ...` : text; +// Default (empty) create draft. Discard / open-create resets to this. +const EMPTY_DRAFT: HolidayInstanceData = { + instanceName: '', + baseHolidayId: NO_HOLIDAY, + patternId: NO_PATTERN, + note: '' +}; + +type ModalMode = 'create' | 'edit'; + +/* + * Small yes/no confirmation modal shaped like OED's ConfirmActionModalComponent. + * TODO(repo): once this file lives in the OED repo, replace this local + * stand-in with the shared ConfirmActionModalComponent. + */ +interface ConfirmModalProps { + isOpen: boolean; + message: string; + confirmText: string; + // 'danger' for delete, 'primary'/'secondary' otherwise. + confirmColor: string; + onConfirm: () => void; + onCancel: () => void; +} + +function ConfirmModal(props: ConfirmModalProps) { + return ( + + {props.message} + + + + + + ); +} + /** - * Holiday Instance page. Raw holidays appear as Inbox cards; clicking one opens - * a modal to lock it to a day pattern, producing a configured instance (Outbox) - * that the Holiday Instance Group page will consume. + * Holiday Instance page. Follows OED's standard entity-page pattern (cf. Units): + * 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 Instance page element */ export default function HolidayInstancePage() { /* Data (mock seam) */ - const { data: availableHolidaysData, isFetching } = useHolidaysMock(); - const { data: dayPatterns } = useDayPatternsMock(); + const { data: holidaysData, isFetching: holidaysFetching } = useHolidaysMock(); + const { data: dayPatterns, isFetching: patternsFetching } = useDayPatternsMock(); + const { data: instances, addInstance, editInstance, deleteInstance } = useHolidayInstancesMock(); - /* Local state */ - // Inbox: raw holidays not yet configured. Seeded from the fetch, then - // items are removed as they get configured into instances. - const [availableHolidays, setAvailableHolidays] = useState([]); - // Outbox: configured instances, ready for the grouping page. - const [configuredInstances, setConfiguredInstances] = useState([]); + /* + * REGION STUB (meeting 4): when the region selector lands, `availableHolidays` + * becomes "holidays for the selected region(s)" instead of the full list, and + * a for region gets added to the page header. The rest + * of this component does not need to change — it only reads `availableHolidays`. + * + * Sorted by name (meeting 4: ~50 holidays/region; date sorting is awkward + * because the date varies by year, so name is the safe default). + */ + const availableHolidays = useMemo( + () => [...holidaysData].sort((a, b) => a.name.localeCompare(b.name)), + [holidaysData] + ); - // Modal + active form draft + /* ---- Local (modal) state — untied to global state until Save ---- */ const [showModal, setShowModal] = useState(false); - const [activeHoliday, setActiveHoliday] = useState(null); - const [draftName, setDraftName] = useState(''); - const [draftPatternId, setDraftPatternId] = useState(NO_PATTERN); - const [draftNote, setDraftNote] = useState(''); + 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(EMPTY_DRAFT); + // Snapshot of the draft at open, used for the dirty check. + const [initialDraft, setInitialDraft] = useState(EMPTY_DRAFT); + // Confirmation sub-modals: unsaved-changes warning and delete guard. + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - // Seed the Inbox once the (mock) fetch resolves. - useEffect(() => { - setAvailableHolidays(availableHolidaysData); - }, [availableHolidaysData]); - - /* Derived validation — matches the conversion modal's invalid/disabled pattern */ - const isNameInvalid = draftName.trim().length === 0; - const isPatternInvalid = draftPatternId === NO_PATTERN; - const isValid = !isNameInvalid && !isPatternInvalid; - - /* Handlers */ - const handleCardClick = (holiday: BaseHoliday) => { - setActiveHoliday(holiday); - setDraftName(holiday.name); // Pre-fill name to save the user time - setDraftPatternId(NO_PATTERN); - setDraftNote(''); + /* ---- Lookups (names are derived; IDs stay internal) ---- */ + // TODO(region): once holidays are region-filtered, an instance whose base + // holiday falls outside the fetched set would have no name here. The + // instances query should return the joined holiday name (or names should be + // fetched by id) — until then, fall back to a visible placeholder rather + // than rendering blank. + const holidayName = (id: number) => + availableHolidays.find(h => h.id === id)?.name ?? STRINGS.baseHolidayUnknown; + const patternName = (id: number) => + dayPatterns.find(p => p.id === id)?.name ?? ''; + + /* + * Searchable dropdown options (meeting 4: with up to ~50 holidays the + * dropdown should be searchable by name). react-select is already an OED + * dependency (meter/group pickers), and it handles the type-to-filter + * behavior itself — one combobox instead of a filter box + select. + * TODO(repo): if OED's shared SingleSelectComponent fits, swap to it. + */ + const holidayOptions = useMemo( + () => availableHolidays.map(h => ({ value: h.id, label: h.name })), + [availableHolidays] + ); + const selectedHolidayOption = + holidayOptions.find(o => o.value === draft.baseHolidayId) ?? null; + + /* ---- Validation (invalid/disabled pattern from the conversion modal) ---- */ + const isNameInvalid = draft.instanceName.trim().length === 0; + const isHolidayInvalid = draft.baseHolidayId === NO_HOLIDAY; + const isPatternInvalid = draft.patternId === NO_PATTERN; + const isValid = !isNameInvalid && !isHolidayInvalid && !isPatternInvalid; + + // In create mode initialDraft === EMPTY_DRAFT, so isDirty doubles as the + // "did they type anything at all" check for the unsaved-changes warning. + const isDirty = + draft.instanceName !== initialDraft.instanceName || + draft.baseHolidayId !== initialDraft.baseHolidayId || + draft.patternId !== initialDraft.patternId || + draft.note !== initialDraft.note; + + // Save is gated on validity AND, in edit mode, on an actual change being made + // (mirrors OED: the Save button only lights up once something really changes). + const canSave = isValid && (modalMode === 'create' || isDirty); + + /* ---- Handlers ---- */ + const openCreate = () => { + setModalMode('create'); + setEditingId(null); + setDraft(EMPTY_DRAFT); + setInitialDraft(EMPTY_DRAFT); + setShowModal(true); + }; + + const openEdit = (instance: HolidayInstance) => { + const asDraft: HolidayInstanceData = { + instanceName: instance.instanceName, + baseHolidayId: instance.baseHolidayId, + patternId: instance.patternId, + note: instance.note + }; + setModalMode('edit'); + setEditingId(instance.id); + setDraft(asDraft); + setInitialDraft(asDraft); // dirty check compares against this setShowModal(true); }; - const handleClose = () => { + // Unconditional close + reset. Only reachable via Save, or after the user + // confirms they want to lose their changes. + const doClose = () => { setShowModal(false); - setActiveHoliday(null); + setShowCloseConfirm(false); + setShowDeleteConfirm(false); + setEditingId(null); + setDraft(EMPTY_DRAFT); + setInitialDraft(EMPTY_DRAFT); + }; + + /* + * Close attempt (backdrop click, X, or Discard). OED pattern (meeting 4): + * clicking off a modal with NO changes is safe — close silently. With + * changes, pop the "you're about to lose your work" warning. Discard goes + * through the same gate on purpose: mis-clicking Discard instead of Save is + * exactly the accidental loss Steve described. + */ + const attemptClose = () => { + if (isDirty) { + setShowCloseConfirm(true); + } else { + doClose(); + } }; const handleSave = () => { - if (!activeHoliday || !isValid) { + if (!canSave) { return; } - const pattern = dayPatterns.find(p => p.id === draftPatternId); - const newInstance: ConfiguredInstance = { - instanceName: draftName, - baseHolidayId: activeHoliday.id, - baseHolidayName: activeHoliday.name, - patternId: draftPatternId, - patternName: pattern ? pattern.name : '', - note: draftNote - }; - // Move from Inbox to Outbox. - // When the real API is wired, this is where addHolidayInstanceMutation(payload) goes. - setConfiguredInstances(prev => [...prev, newInstance]); - setAvailableHolidays(prev => prev.filter(h => h.id !== activeHoliday.id)); - handleClose(); + if (modalMode === 'create') { + addInstance(draft); + } else if (editingId !== null) { + // baseHolidayId is immutable in edit mode (locked field below), so the + // patch deliberately omits it — see HolidayInstancePatch. + editInstance({ + id: editingId, + instanceName: draft.instanceName, + patternId: draft.patternId, + note: draft.note + }); + } + doClose(); }; - if (isFetching) { + const handleDeleteConfirmed = () => { + if (editingId === null) { + return; + } + deleteInstance(editingId); + doClose(); + }; + + if (holidaysFetching || patternsFetching) { return (
-

Loading holidays...

+

{STRINGS.loading}

); @@ -170,91 +414,123 @@ export default function HolidayInstancePage() { return (
- {/* ---------- Section 1: Inbox ---------- */} -

Available Holidays

-

Click a holiday to configure its rate schedule.

+ {/* ---------- Page title (mirrors "Units") ---------- + TODO(repo): Units renders a TooltipMarkerComponent help icon next + to the title — add it once this lives in the OED repo. */} +

{STRINGS.pageTitle}

-
- {availableHolidays.map(holiday => ( -
handleCardClick(holiday)} - title={holiday.name} - > -
- {truncate(holiday.name)} -
-
- Date: {holiday.date} -
-
- -
-
- ))} - {availableHolidays.length === 0 && ( -

All available holidays have been configured.

- )} + {/* ---------- Create button (mirrors "Create a Unit") ---------- */} +
+
- {/* ---------- Section 2: Outbox ---------- */} -

Configured Instances

-

Ready to be picked up on the Holiday Instance Group page.

- + {/* ---------- Instance cards ---------- */}
- {configuredInstances.map((instance, index) => ( -
+ {instances.map(instance => ( +
{truncate(instance.instanceName)}
+ {/* Bold "Label:" + value lines, like the Units cards. */}
- Base Holiday: {instance.baseHolidayName} + {STRINGS.baseHoliday} {holidayName(instance.baseHolidayId)}
- Day Pattern: {instance.patternName} + {STRINGS.dayPattern} {patternName(instance.patternId)} +
+ {instance.note.trim().length > 0 && ( +
+ {STRINGS.note} {truncate(instance.note)} +
+ )} + {/* Mirrors the Units card's "Details/Edit Unit" button. */} +
+
))} - {configuredInstances.length === 0 && ( -

No instances configured yet.

+ {instances.length === 0 && ( +

{STRINGS.noRates}

)}
- {/* ---------- Configuration Modal ---------- */} - - Configure Holiday Instance + {/* ---------- Create / Edit modal ---------- */} + + {/* Headers mirror Units: "Create a Holiday Rate" / "Details/Edit Holiday Rate". */} + + {modalMode === 'create' ? STRINGS.createButton : STRINGS.detailsButton} + - {/* Base holiday is locked in by which card was clicked -> disabled */} + {/* Base holiday. + CREATE: a required searchable combobox (react-select) — click + it and type to filter, e.g. "Thanks" -> Thanksgiving. Name- + sorted, up to ~50 per region (meeting 4). + EDIT: locked. Meeting-4 decision — changing the region/base + holiday effectively invalidates the instance, so it can't be + edited in place; delete + create a new one instead. */} - - + + {modalMode === 'create' ? ( + <> + + )} {/* Instance name — required */} - + setDraftName(e.target.value)} + value={draft.instanceName} + onChange={e => + setDraft(d => ({ ...d, instanceName: e.target.value })) + } invalid={isNameInvalid} /> - Name is required. + {STRINGS.nameRequired} @@ -262,52 +538,89 @@ export default function HolidayInstancePage() { {/* Day pattern — required. Placeholder option mirrors the conversion modal's hidden/disabled -999 sentinel option. */} - + setDraftPatternId(Number(e.target.value))} + value={draft.patternId} + onChange={e => + setDraft(d => ({ ...d, patternId: Number(e.target.value) })) + } invalid={isPatternInvalid} > {dayPatterns.map(pattern => ( ))} - A day pattern is required. + {STRINGS.dayPatternRequired} {/* Note — optional */} - + setDraftNote(e.target.value)} + value={draft.note} + onChange={e => setDraft(d => ({ ...d, note: e.target.value }))} /> + {/* Footer mirrors the Units modal: "Delete Unit" (danger, left) / + "Discard Changes" / Save (primary). Save is mode-dependent and + self-explanatory (meeting 4: every button should say the same, + self-explanatory thing). */} - + )} + - + + {/* Unsaved-changes warning (meeting 4: "you're about to lose your work"). */} + setShowCloseConfirm(false)} + /> + + {/* Delete guard. */} + setShowDeleteConfirm(false)} + />
); } @@ -321,6 +634,18 @@ const subtitleStyle: React.CSSProperties = { fontWeight: 500 }; -const clickableCardStyle: React.CSSProperties = { - cursor: 'pointer' +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. `base` is react-select's +// CSSObjectWithLabel; typed loosely to avoid pinning a react-select version. +const invalidSelectStyles = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: (base: any) => ({ + ...base, + borderColor: '#dc3545', + '&:hover': { borderColor: '#dc3545' } + }) }; From 82c5d1443622a45b35d5f6c6ac51faed74ddebae Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 15 Jul 2026 14:14:19 -0700 Subject: [PATCH 142/170] Edited HolidayInstanceGroupComponent to implement the view that displays instance group cards --- .../holiday/HolidayInstanceGroupComponent.tsx | 591 +++--------------- 1 file changed, 84 insertions(+), 507 deletions(-) diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index 3446a4c0c4..cf74563026 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -3,522 +3,87 @@ * 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 { - Alert, Button, FormFeedback, FormGroup, Input, Label, - Pagination, PaginationItem, PaginationLink, Table, Modal, ModalHeader, ModalBody, ModalFooter -} from 'reactstrap'; import { titleStyle } from '../../styles/modalStyle'; -import { HolidayInstance } from 'types/redux/holiday'; -import { testHolidayInstances } from './holidayInstanceTestData'; // For testing purposes, remove this import in production +import { HolidayInstance } from '../../types/redux/holiday'; import CreateHolidayInstanceGroupModalComponent from './CreateHolidayInstanceGroupModalComponent'; +import { HolidayInstanceGroupData } from './EditHolidayInstanceGroupModalComponent'; +import HolidayInstanceGroupViewComponent from './HolidayInstanceGroupViewComponent'; +import { testHolidayInstances } from './holidayInstanceTestData'; // For testing purposes, remove in production. - -// interface HolidayInstanceGroup { -// id: number; -// holidayInstanceIds: number[]; -// } - -// interface HolidayInstanceGroupComponentProps { -// holidayInstances?: HolidayInstance[]; -// holidayInstanceGroups?: HolidayInstanceGroup[]; -// handleUpdateHolidayLimit?: (holidayLimit: number) => void; -// handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[]) => void; -// handleUpdateHolidayInstanceGroup?: (holidayInstanceGroupId: number, holidayInstanceIds: number[]) => void; -// handleDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; -// } - -// /** -// * Defines the holiday instance group selection page. -// * @param props Holiday instance data and optional action handlers -// * @returns Holiday instance group page element -// */ -// export default function HolidayInstanceGroupComponent(props: HolidayInstanceGroupComponentProps) { -// const holidayInstances = props.holidayInstances ?? testHolidayInstances; -// const [holidayLimitText, setHolidayLimitText] = React.useState(PER_PAGE.toString()); -// const [displayLimit, setDisplayLimit] = React.useState(PER_PAGE); -// const [currentPage, setCurrentPage] = React.useState(1); -// const [showAllHolidays, setShowAllHolidays] = React.useState(false); -// const [selectedHolidayInstanceIds, setSelectedHolidayInstanceIds] = React.useState([]); -// const [createdHolidayInstanceGroups, setCreatedHolidayInstanceGroups] = React.useState([]); -// const [selectedHolidayInstanceGroupId, setSelectedHolidayInstanceGroupId] = React.useState(''); -// const [selectedHolidayInstanceGroupHolidayIds, setSelectedHolidayInstanceGroupHolidayIds] = React.useState([]); -// const [deleteHolidayInstanceGroupModalOpen, setDeleteHolidayInstanceGroupModalOpen] = React.useState(false); -// const holidayLimit = Number(holidayLimitText); -// // Modal state for displaying full log message -// const [modalOpen, setModalOpen] = React.useState(false); -// // holiday name to display in the modal header -// const [modelHeader, setModelHeader] = React.useState(''); -// // Holiday note to display in the modal -// const [modalHolidayNote, setModalHolidayNote] = React.useState(''); -// const holidayLimitInvalid = holidayLimitText.trim() === '' -// || !Number.isInteger(holidayLimit) -// || holidayLimit < 1; -// const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; -// const selectedHolidayInstanceGroup = holidayInstanceGroups.find(holidayInstanceGroup => -// holidayInstanceGroup.id.toString() === selectedHolidayInstanceGroupId); -// const viewingHolidayInstanceGroup = selectedHolidayInstanceGroup !== undefined; -// const tableHolidayInstances = viewingHolidayInstanceGroup -// ? holidayInstances.filter(holidayInstance => -// selectedHolidayInstanceGroupHolidayIds.includes(holidayInstance.id)) -// : holidayInstances; -// const totalPages = Math.max(1, Math.ceil(tableHolidayInstances.length / displayLimit)); -// const paginatedHolidayInstances = showAllHolidays -// ? tableHolidayInstances -// : tableHolidayInstances.slice((currentPage - 1) * displayLimit, currentPage * displayLimit); -// const selectedHolidayInstanceIdsForGroup = holidayInstances -// .filter(holidayInstance => selectedHolidayInstanceIds.includes(holidayInstance.id)) -// .map(holidayInstance => holidayInstance.id); - -// React.useEffect(() => { -// if (currentPage > totalPages) { -// setCurrentPage(totalPages); -// } -// }, [currentPage, totalPages]); - -// const handleUpdateHolidayLimit = () => { -// if (holidayLimitInvalid) { -// return; -// } - -// setDisplayLimit(holidayLimit); -// setCurrentPage(1); -// setShowAllHolidays(false); -// props.handleUpdateHolidayLimit?.(holidayLimit); -// }; - -// const handleHolidayInstanceGroupChange = (holidayInstanceGroupId: string) => { -// const nextHolidayInstanceGroup = holidayInstanceGroups.find(holidayInstanceGroup => -// holidayInstanceGroup.id.toString() === holidayInstanceGroupId); - -// setSelectedHolidayInstanceGroupId(holidayInstanceGroupId); -// setSelectedHolidayInstanceGroupHolidayIds(nextHolidayInstanceGroup?.holidayInstanceIds ?? []); -// setSelectedHolidayInstanceIds([]); -// setCurrentPage(1); -// setShowAllHolidays(false); -// }; - -// const handleCreateNewHolidayInstanceGroup = () => { -// setSelectedHolidayInstanceGroupId(''); -// setSelectedHolidayInstanceGroupHolidayIds([]); -// setSelectedHolidayInstanceIds([]); -// setCurrentPage(1); -// setShowAllHolidays(false); -// }; - -// //open modal to show full holiday name and note -// const handleHolidayNoteModal = (holidayName: string, holidayNote: string) => { -// setModelHeader(holidayName); -// setModalHolidayNote(holidayNote); -// setModalOpen(true); -// }; - -// const handleHolidayInstanceSelect = (holidayInstanceId: number) => { -// setSelectedHolidayInstanceIds(currentIds => currentIds.includes(holidayInstanceId) -// ? currentIds.filter(id => id !== holidayInstanceId) -// : [...currentIds, holidayInstanceId]); -// }; - -// const handleRemoveHolidayInstanceFromGroup = (holidayInstanceId: number) => { -// setSelectedHolidayInstanceGroupHolidayIds(currentIds => -// currentIds.filter(id => id !== holidayInstanceId)); -// }; - -// const handleSaveHolidayInstanceGroupChanges = () => { -// if (selectedHolidayInstanceGroup === undefined) { -// return; -// } - -// props.handleUpdateHolidayInstanceGroup?.( -// selectedHolidayInstanceGroup.id, -// selectedHolidayInstanceGroupHolidayIds -// ); - -// if (props.holidayInstanceGroups === undefined) { -// setCreatedHolidayInstanceGroups(currentGroups => currentGroups.map(group => -// group.id === selectedHolidayInstanceGroup.id -// ? { ...group, holidayInstanceIds: selectedHolidayInstanceGroupHolidayIds } -// : group)); -// } -// }; - -// const handleDeleteHolidayInstanceGroup = () => { -// if (selectedHolidayInstanceGroup === undefined) { -// return; -// } - -// props.handleDeleteHolidayInstanceGroup?.(selectedHolidayInstanceGroup.id); - -// if (props.holidayInstanceGroups === undefined) { -// setCreatedHolidayInstanceGroups(currentGroups => -// currentGroups.filter(group => group.id !== selectedHolidayInstanceGroup.id)); -// } - -// setDeleteHolidayInstanceGroupModalOpen(false); -// setSelectedHolidayInstanceGroupId(''); -// setSelectedHolidayInstanceGroupHolidayIds([]); -// setCurrentPage(1); -// setShowAllHolidays(false); -// }; - -// const handleCreateHolidayInstanceGroup = () => { -// if (selectedHolidayInstanceIdsForGroup.length === 0) { -// return; -// } - -// props.handleCreateHolidayInstanceGroup?.(selectedHolidayInstanceIdsForGroup); - -// if (props.holidayInstanceGroups === undefined) { -// setCreatedHolidayInstanceGroups(currentGroups => { -// const nextHolidayInstanceGroupId = Math.max(0, ...currentGroups.map(group => group.id)) + 1; - -// return [ -// ...currentGroups, -// { -// id: nextHolidayInstanceGroupId, -// holidayInstanceIds: selectedHolidayInstanceIdsForGroup -// } -// ]; -// }); -// } - -// setSelectedHolidayInstanceIds([]); -// }; - -// return ( -//
-//

-// -//

- -//
-// -// -// handleHolidayInstanceGroupChange(e.target.value)} -// > -// -// {holidayInstanceGroups.map(holidayInstanceGroup => ( -// -// ))} -// -// -// -// -// -// setHolidayLimitText(e.target.value)} -// /> -// -// -// -// -// -//
- -// {/* Display holiday instances table */} -// {tableHolidayInstances.length > 0 ? ( -//
-// -// -// -// -// -// -// -// -// -// {paginatedHolidayInstances.map(holidayInstance => { -// const selected = selectedHolidayInstanceIds.includes(holidayInstance.id); - -// return ( -// -// -// -// {viewingHolidayInstanceGroup ? ( -// -// ) : ( -// -// )} -// -// ); -// })} -// -//
-// -// -// -// -// {viewingHolidayInstanceGroup ? ( -// -// ) : ( -// -// )} -//
-// {holidayInstance.name} -// handleHolidayNoteModal(holidayInstance.name, holidayInstance.note)} -// > -// {holidayInstance.note.length > 80 ? `${holidayInstance.note.slice(0, 80)}...` : holidayInstance.note} -// -// -// -// -//
-//
-// ) : ( -// -// -// -// )} - -// {/* Pagination */} -// {!showAllHolidays && tableHolidayInstances.length > 0 && ( -// -// -// setCurrentPage(1)} /> -// -// setCurrentPage(currentPage - 1)} /> -// - -// {Array.from({ length: totalPages }, (_, index) => ( -// -// setCurrentPage(index + 1)}> -// {index + 1} -// -// -// ))} - -// -// setCurrentPage(currentPage + 1)} /> -// -// setCurrentPage(totalPages)} /> -// -// -// )} - -// {/* Action buttons for showing all holiday instances and creating holiday instance groups */} -// {(tableHolidayInstances.length > 0 || viewingHolidayInstanceGroup) && ( -//
-// {tableHolidayInstances.length > 0 && ( -// -// )} -// {!viewingHolidayInstanceGroup && ( -// -// )} -// {viewingHolidayInstanceGroup && ( -//
-// -// -//
-// )} -//
-// )} - -// {/* Modal for displaying full holiday note */} -// setModalOpen(false)} centered> -// setModalOpen(false)}> -// {modelHeader} -// -// -// {modalHolidayNote} -// -// - -// setDeleteHolidayInstanceGroupModalOpen(false)} -// centered> -// setDeleteHolidayInstanceGroupModalOpen(false)}> -// -// -// -// -// -// -// -// -// -// -//
-// ); -// } - -// const pageStyle: React.CSSProperties = { -// padding: '4rem 0 2rem', -// width: '100%' -// }; - -// const holidayFilterStyle: React.CSSProperties = { -// display: 'flex', -// justifyContent: 'center', -// flexWrap: 'wrap', -// gap: '1.5%', -// alignItems: 'center', -// margin: 'auto', -// width: '92%', -// maxWidth: '72rem', -// padding: '20px', -// border: '2px solid lightgrey' -// }; - -// const holidayInstanceGroupSelectStyle: React.CSSProperties = { -// margin: 0, -// width: '18rem' -// }; - -// const holidayLimitGroupStyle: React.CSSProperties = { -// margin: 0, -// width: '21rem' -// }; - -// const tableWrapStyle: React.CSSProperties = { -// margin: '0 auto', -// width: '92%' -// }; - -// const tableStyle: React.CSSProperties = { -// width: '90%', -// margin: '1% auto' -// }; - -// const bodyStyle: React.CSSProperties = { -// textAlign: 'left' -// }; - -// const actionContainerStyle: React.CSSProperties = { -// alignItems: 'center', -// display: 'flex', -// flexDirection: 'column', -// gap: '1.25rem' -// }; - -// const holidayInstanceGroupActionStyle: React.CSSProperties = { -// display: 'flex', -// gap: '1rem', -// justifyContent: 'center', -// margin: '0% 40% 1%' -// }; - -// const emptyHolidayStyle: React.CSSProperties = { -// margin: '2rem auto', -// maxWidth: '40rem', -// textAlign: 'center' -// }; interface HolidayInstanceGroupComponentProps { holidayInstances?: HolidayInstance[]; - handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[], note?: string) => void; + holidayInstanceGroups?: HolidayInstanceGroupData[]; + handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[], note: string) => void; + handleUpdateHolidayInstanceGroup?: ( + holidayInstanceGroupId: number, + holidayInstanceIds: number[], + note: string + ) => void; + handleDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; } /** - * Defines the holiday instance group page. - * @param props Available holiday instances and the create handler. + * Defines the holiday instance group card page. + * @param props Holiday instance data, group data, and optional action handlers. * @returns Holiday instance group page element. */ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGroupComponentProps) { + const holidayInstances = props.holidayInstances ?? testHolidayInstances; + const [createdHolidayInstanceGroups, setCreatedHolidayInstanceGroups] = useState([]); + const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; + const usingLocalHolidayInstanceGroups = props.holidayInstanceGroups === undefined; + + const handleCreateHolidayInstanceGroup = (holidayInstanceIds: number[], note: string) => { + props.handleCreateHolidayInstanceGroup?.(holidayInstanceIds, note); + + if (usingLocalHolidayInstanceGroups) { + setCreatedHolidayInstanceGroups(currentGroups => { + const nextId = Math.max(0, ...currentGroups.map(group => group.id)) + 1; + + return [ + ...currentGroups, + { + id: nextId, + holidayInstanceIds, + note + } + ]; + }); + } + }; + + const handleUpdateHolidayInstanceGroup = ( + holidayInstanceGroupId: number, + holidayInstanceIds: number[], + note: string + ) => { + props.handleUpdateHolidayInstanceGroup?.( + holidayInstanceGroupId, + holidayInstanceIds, + note + ); + + if (usingLocalHolidayInstanceGroups) { + setCreatedHolidayInstanceGroups(currentGroups => currentGroups.map(group => + group.id === holidayInstanceGroupId + ? { ...group, holidayInstanceIds, note } + : group + )); + } + }; + + const handleDeleteHolidayInstanceGroup = (holidayInstanceGroupId: number) => { + props.handleDeleteHolidayInstanceGroup?.(holidayInstanceGroupId); + + if (usingLocalHolidayInstanceGroups) { + setCreatedHolidayInstanceGroups(currentGroups => + currentGroups.filter(group => group.id !== holidayInstanceGroupId) + ); + } + }; + return (
@@ -530,11 +95,23 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou
- props.handleCreateHolidayInstanceGroup?.(holidayInstanceIds, note)} + holidayInstances={holidayInstances} + onCreateHolidayInstanceGroup={handleCreateHolidayInstanceGroup} />
+
+ {[...holidayInstanceGroups] + .sort((firstGroup, secondGroup) => firstGroup.id - secondGroup.id) + .map(holidayInstanceGroup => ( + + ))} +
); From 2d75aa9b164c39c682864ccdf41e5f555f93022f Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 15 Jul 2026 16:27:43 -0700 Subject: [PATCH 143/170] added the ability to add a name to holiday instance groups --- ...eateHolidayInstanceGroupModalComponent.tsx | 29 +++++++++-- ...EditHolidayInstanceGroupModalComponent.tsx | 28 ++++++++++- .../holiday/HolidayInstanceGroupComponent.tsx | 14 ++++-- .../HolidayInstanceGroupViewComponent.jsx | 10 ++-- .../holiday/holidayInstanceTestData.ts | 50 +++++++++---------- 5 files changed, 88 insertions(+), 43 deletions(-) diff --git a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx index 0cb1230a33..154607c69a 100644 --- a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx @@ -25,7 +25,7 @@ import { HolidayInstance } from '../../types/redux/holiday'; interface CreateHolidayInstanceGroupModalComponentProps { holidayInstances: HolidayInstance[]; - onCreateHolidayInstanceGroup: (holidayInstanceIds: number[], note: string) => void; + onCreateHolidayInstanceGroup: (name: string, holidayInstanceIds: number[], note: string) => void; } /** @@ -37,6 +37,7 @@ export default function CreateHolidayInstanceGroupModalComponent( props: CreateHolidayInstanceGroupModalComponentProps ) { const [showModal, setShowModal] = useState(false); + const [name, setName] = useState(''); const [holidayInstanceIds, setHolidayInstanceIds] = useState([]); const [note, setNote] = useState(''); @@ -54,6 +55,7 @@ export default function CreateHolidayInstanceGroupModalComponent( ); const resetState = () => { + setName(''); setHolidayInstanceIds([]); setNote(''); }; @@ -70,12 +72,13 @@ export default function CreateHolidayInstanceGroupModalComponent( return; } - props.onCreateHolidayInstanceGroup(holidayInstanceIds, note); + props.onCreateHolidayInstanceGroup(name.trim(), holidayInstanceIds, note); setShowModal(false); resetState(); }; - const validHolidayInstanceGroup = holidayInstanceIds.length > 0; + const validName = name.trim() !== ''; + const validHolidayInstanceGroup = validName && holidayInstanceIds.length > 0; return ( <> @@ -95,6 +98,24 @@ export default function CreateHolidayInstanceGroupModalComponent( + + + setName(e.target.value)} + required + invalid={!validName} + /> + + + + + {/* The holiday instances in this group */} @@ -116,7 +137,7 @@ export default function CreateHolidayInstanceGroupModalComponent( setHolidayInstanceIds(updatedHolidayInstanceIds); }} /> - {!validHolidayInstanceGroup && ( + {holidayInstanceIds.length === 0 && ( diff --git a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx index a1fb0c727d..1607631e08 100644 --- a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx @@ -26,6 +26,7 @@ import { HolidayInstance } from '../../types/redux/holiday'; export interface HolidayInstanceGroupData { id: number; + name: string; holidayInstanceIds: number[]; note: string; } @@ -38,6 +39,7 @@ interface EditHolidayInstanceGroupModalComponentProps { handleClose: () => void; onEditHolidayInstanceGroup: ( holidayInstanceGroupId: number, + name: string, holidayInstanceIds: number[], note: string ) => void; @@ -52,6 +54,7 @@ interface EditHolidayInstanceGroupModalComponentProps { export default function EditHolidayInstanceGroupModalComponent( props: EditHolidayInstanceGroupModalComponentProps ) { + const [name, setName] = useState(props.holidayInstanceGroup.name ?? ''); const [holidayInstanceIds, setHolidayInstanceIds] = useState( props.holidayInstanceGroup.holidayInstanceIds ); @@ -70,9 +73,11 @@ export default function EditHolidayInstanceGroupModalComponent( const selectedHolidayInstanceOptions = holidayInstanceOptions.filter(option => holidayInstanceIds.includes(option.value) ); - const validHolidayInstanceGroup = holidayInstanceIds.length > 0; + const validName = name.trim() !== ''; + const validHolidayInstanceGroup = validName && holidayInstanceIds.length > 0; const resetState = React.useCallback(() => { + setName(props.holidayInstanceGroup.name ?? ''); setHolidayInstanceIds(props.holidayInstanceGroup.holidayInstanceIds); setNote(props.holidayInstanceGroup.note); }, [props.holidayInstanceGroup]); @@ -96,6 +101,7 @@ export default function EditHolidayInstanceGroupModalComponent( props.handleClose(); props.onEditHolidayInstanceGroup( props.holidayInstanceGroup.id, + name.trim(), holidayInstanceIds, note ); @@ -136,6 +142,24 @@ export default function EditHolidayInstanceGroupModalComponent( + + + setName(e.target.value)} + required + invalid={!validName} + /> + + + + + {/* The holiday instances in this group */} @@ -157,7 +181,7 @@ export default function EditHolidayInstanceGroupModalComponent( setHolidayInstanceIds(updatedHolidayInstanceIds); }} /> - {!validHolidayInstanceGroup && ( + {holidayInstanceIds.length === 0 && ( diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index cf74563026..463f87de8b 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -15,9 +15,10 @@ import { testHolidayInstances } from './holidayInstanceTestData'; // For testing interface HolidayInstanceGroupComponentProps { holidayInstances?: HolidayInstance[]; holidayInstanceGroups?: HolidayInstanceGroupData[]; - handleCreateHolidayInstanceGroup?: (holidayInstanceIds: number[], note: string) => void; + handleCreateHolidayInstanceGroup?: (name: string, holidayInstanceIds: number[], note: string) => void; handleUpdateHolidayInstanceGroup?: ( holidayInstanceGroupId: number, + name: string, holidayInstanceIds: number[], note: string ) => void; @@ -35,8 +36,8 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; const usingLocalHolidayInstanceGroups = props.holidayInstanceGroups === undefined; - const handleCreateHolidayInstanceGroup = (holidayInstanceIds: number[], note: string) => { - props.handleCreateHolidayInstanceGroup?.(holidayInstanceIds, note); + const handleCreateHolidayInstanceGroup = (name: string, holidayInstanceIds: number[], note: string) => { + props.handleCreateHolidayInstanceGroup?.(name, holidayInstanceIds, note); if (usingLocalHolidayInstanceGroups) { setCreatedHolidayInstanceGroups(currentGroups => { @@ -46,6 +47,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou ...currentGroups, { id: nextId, + name, holidayInstanceIds, note } @@ -56,11 +58,13 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou const handleUpdateHolidayInstanceGroup = ( holidayInstanceGroupId: number, + name: string, holidayInstanceIds: number[], note: string ) => { props.handleUpdateHolidayInstanceGroup?.( holidayInstanceGroupId, + name, holidayInstanceIds, note ); @@ -68,7 +72,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou if (usingLocalHolidayInstanceGroups) { setCreatedHolidayInstanceGroups(currentGroups => currentGroups.map(group => group.id === holidayInstanceGroupId - ? { ...group, holidayInstanceIds, note } + ? { ...group, name, holidayInstanceIds, note } : group )); } @@ -115,4 +119,4 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou
); -} \ No newline at end of file +} diff --git a/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx index cb4107723b..2f711abcfb 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx @@ -12,9 +12,9 @@ import EditHolidayInstanceGroupModalComponent from './EditHolidayInstanceGroupMo /** * Defines a holiday instance group information card. * @param {object} props Component properties. - * @param {{id: number, holidayInstanceIds: number[], note: string}} props.holidayInstanceGroup Group data. + * @param {{id: number, name: string, holidayInstanceIds: number[], note: string}} props.holidayInstanceGroup Group data. * @param {Array<{id: number, name: string}>} props.holidayInstances Available holiday instances. - * @param {(id: number, holidayInstanceIds: number[], note: string) => void} props.onEditHolidayInstanceGroup Edit handler. + * @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. */ @@ -41,11 +41,7 @@ export default function HolidayInstanceGroupViewComponent(props) { return (
- + {props.holidayInstanceGroup.name}
diff --git a/src/client/app/components/holiday/holidayInstanceTestData.ts b/src/client/app/components/holiday/holidayInstanceTestData.ts index 11be11ded7..6415e7fe47 100644 --- a/src/client/app/components/holiday/holidayInstanceTestData.ts +++ b/src/client/app/components/holiday/holidayInstanceTestData.ts @@ -13,175 +13,175 @@ export interface HolidayInstance { export const testHolidayInstances: HolidayInstance[] = [ { id: 1, - name: 'New Year\'s Day - Standard Holiday Rate', + name: 'New Year\'s Day', note: 'Applies the standard holiday day pattern for New Year\'s Day.', holidayId: 1, dayPatternId: 101 }, { id: 2, - name: 'Martin Luther King Jr. Day - Reduced Demand', + name: 'Martin Luther King Jr. Day', note: 'Observed holiday with reduced commercial demand.', holidayId: 2, dayPatternId: 102 }, { id: 3, - name: 'Presidents\' Day - No Override', + name: 'Presidents\' Day', note: 'Listed for review, but currently uses the normal weekday pattern.', holidayId: 3, dayPatternId: 103 }, { id: 4, - name: 'Memorial Day - Holiday Rate', + name: 'Memorial Day', note: 'Applies all-day holiday pricing.', holidayId: 4, dayPatternId: 101 }, { id: 5, - name: 'Juneteenth - Federal Holiday', + name: 'Juneteenth', note: 'Federal holiday with expected lower commercial load.', holidayId: 5, dayPatternId: 102 }, { id: 6, - name: 'Independence Day - Summer Peak', + name: 'Independence Day', note: 'Holiday rate applies, but cooling demand may remain elevated.', holidayId: 6, dayPatternId: 104 }, { id: 7, - name: 'Labor Day - Holiday Rate', + name: 'Labor Day', note: 'Applies the utility holiday rate pattern.', holidayId: 7, dayPatternId: 101 }, { id: 8, - name: 'Columbus Day - Standard Pattern', + name: 'Columbus Day', note: 'No special rate override for this test instance.', holidayId: 8, dayPatternId: 103 }, { id: 9, - name: 'Veterans Day - Observed Holiday', + name: 'Veterans Day', note: 'Observed holiday with moderate demand reduction.', holidayId: 9, dayPatternId: 102 }, { id: 10, - name: 'Thanksgiving Day - Holiday Rate', + name: 'Thanksgiving Day', note: 'Major holiday with low commercial demand.', holidayId: 10, dayPatternId: 101 }, { id: 11, - name: 'Day After Thanksgiving - Optional Holiday', + name: 'Day After Thanksgiving', note: 'Optional bridge holiday used by some locations.', holidayId: 11, dayPatternId: 105 }, { id: 12, - name: 'Christmas Eve - Evening Reduction', + name: 'Christmas Eve', note: 'Partial holiday behavior with lower evening demand.', holidayId: 12, dayPatternId: 106 }, { id: 13, - name: 'Christmas Day - Holiday Rate', + name: 'Christmas Day', note: 'Applies all-day holiday pricing.', holidayId: 13, dayPatternId: 101 }, { id: 14, - name: 'New Year\'s Eve - Peak Watch', + name: 'New Year\'s Eve', note: 'Demand may increase late in the day; no full holiday override.', holidayId: 14, dayPatternId: 107 }, { id: 15, - name: 'Cesar Chavez Day - Regional Holiday', + name: 'Cesar Chavez Day', note: 'California regional holiday for test grouping.', holidayId: 15, dayPatternId: 102 }, { id: 16, - name: 'Good Friday - Optional Closure', + name: 'Good Friday', note: 'Optional closure pattern for supported regions.', holidayId: 16, dayPatternId: 105 }, { id: 17, - name: 'Easter Monday - International', + name: 'Easter Monday', note: 'International holiday test instance.', holidayId: 17, dayPatternId: 105 }, { id: 18, - name: 'Diwali - Cultural Holiday', + name: 'Diwali', note: 'Cultural holiday instance for non-US holiday testing.', holidayId: 18, dayPatternId: 108 }, { id: 19, - name: 'Lunar New Year - International', + name: 'Lunar New Year', note: 'International holiday with location-specific behavior.', holidayId: 19, dayPatternId: 108 }, { id: 20, - name: 'Eid al-Fitr - Regional Holiday', + name: 'Eid al-Fitr', note: 'Regional holiday instance with reduced commercial load.', holidayId: 20, dayPatternId: 108 }, { id: 21, - name: 'Rosh Hashanah - Cultural Holiday', + name: 'Rosh Hashanah', note: 'Cultural holiday test instance.', holidayId: 21, dayPatternId: 108 }, { id: 22, - name: 'Yom Kippur - Cultural Holiday', + name: 'Yom Kippur', note: 'Cultural holiday with low business activity in selected regions.', holidayId: 22, dayPatternId: 108 }, { id: 23, - name: 'Election Day - Civic Holiday', + name: 'Election Day', note: 'Civic holiday test case with normal weekday fallback.', holidayId: 23, dayPatternId: 103 }, { id: 24, - name: 'Utility Flex Holiday - Off Peak', + name: 'Utility Flex Holiday', note: 'Synthetic utility holiday for testing custom groups.', holidayId: 24, dayPatternId: 109 }, { id: 25, - name: 'Emergency Closure Day - Special Pattern', + name: 'Emergency Closure Day', note: 'Synthetic closure day used to test manually created groups.', holidayId: 25, dayPatternId: 110 From c16760a838ec1f3d9fb7b17e3d512ffe70dfb016 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 15 Jul 2026 16:54:41 -0700 Subject: [PATCH 144/170] added a temp api file and added a section under the pages header tab to select the holiday instance group page --- .../app/components/HeaderButtonsComponent.tsx | 9 + .../api/holidayInstanceGroupsApi.temp.ts | 235 ++++++++++++++++++ src/client/app/translations/data.ts | 34 +-- 3 files changed, 245 insertions(+), 33 deletions(-) create mode 100644 src/client/app/redux/api/holidayInstanceGroupsApi.temp.ts diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index 8aa3fe2fb3..3e115c5fbd 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -58,6 +58,7 @@ export default function HeaderButtonsComponent() { shouldAdminButtonDisabled: true, shouldUsersButtonDisabled: true, shouldGroupsButtonDisabled: true, + shouldHolidayInstanceGroupsButtonDisabled: true, shouldMetersButtonDisabled: true, shouldMapsButtonDisabled: true, shouldCSVMetersButtonDisabled: true, @@ -96,6 +97,7 @@ export default function HeaderButtonsComponent() { shouldAdminButtonDisabled: pathname === '/admin', shouldUsersButtonDisabled: pathname === '/users', shouldGroupsButtonDisabled: pathname === '/groups', + shouldHolidayInstanceGroupsButtonDisabled: pathname === '/holiday-instance-groups', shouldMetersButtonDisabled: pathname === '/meters', shouldMapsButtonDisabled: pathname === '/maps', shouldCSVMetersButtonDisabled: pathname === '/csvMeters', @@ -211,6 +213,13 @@ export default function HeaderButtonsComponent() { to="/groups"> + + + ({ + getHolidayInstanceGroups: builder.query({ + query: () => HOLIDAY_INSTANCE_GROUPS_PATH, + providesTags: ['HolidayInstanceGroups'] + }), + + getHolidayGroupMembers: builder.query({ + query: holidayInstanceGroupId => + `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${holidayInstanceGroupId}`, + providesTags: ['HolidayInstanceGroups'] + }), + + getHolidayInstanceGroupsWithMembers: builder.query({ + async queryFn(_argument, _queryApi, _extraOptions, fetchWithBQ) { + const groupsResult = await fetchWithBQ(HOLIDAY_INSTANCE_GROUPS_PATH); + if (groupsResult.error) { + return { error: groupsResult.error }; + } + + const groups = groupsResult.data as HolidayInstanceGroup[]; + const groupsWithMembers: HolidayInstanceGroupWithMembers[] = []; + for (const group of groups) { + const membersResult = await fetchWithBQ( + `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${group.id}` + ); + if (membersResult.error) { + return { error: membersResult.error }; + } + + const members = membersResult.data as HolidayGroupMember[]; + groupsWithMembers.push({ + ...group, + holidayInstanceIds: members.map(member => member.holidayInstanceId) + }); + } + + return { data: groupsWithMembers }; + }, + providesTags: ['HolidayInstanceGroups'] + }), + + addHolidayInstanceGroup: builder.mutation({ + async queryFn(request, _queryApi, _extraOptions, fetchWithBQ) { + const groupResult = await fetchWithBQ({ + url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/addHolidayInstanceGroup`, + method: 'POST', + body: { + name: request.name, + note: request.note + } + }); + if (groupResult.error) { + return { error: groupResult.error }; + } + + const createdGroup = groupResult.data as HolidayInstanceGroup; + for (const holidayInstanceId of request.holidayInstanceIds) { + const memberResult = await fetchWithBQ({ + url: `${HOLIDAY_GROUP_MEMBERS_PATH}/add`, + method: 'POST', + body: { + holidayInstanceGroupId: createdGroup.id, + holidayInstanceId + } + }); + if (memberResult.error) { + return { error: memberResult.error }; + } + } + + return { + data: { + ...createdGroup, + holidayInstanceIds: request.holidayInstanceIds + } + }; + }, + invalidatesTags: ['HolidayInstanceGroups'] + }), + + editHolidayInstanceGroup: builder.mutation({ + async queryFn(request, _queryApi, _extraOptions, fetchWithBQ) { + const membersResult = await fetchWithBQ( + `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${request.id}` + ); + if (membersResult.error) { + return { error: membersResult.error }; + } + + const currentMembers = membersResult.data as HolidayGroupMember[]; + const currentIds = new Set(currentMembers.map(member => member.holidayInstanceId)); + const requestedIds = new Set(request.holidayInstanceIds); + + const groupResult = await fetchWithBQ({ + url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/edit`, + method: 'POST', + body: { + id: request.id, + name: request.name, + note: request.note + } + }); + if (groupResult.error) { + return { error: groupResult.error }; + } + + for (const holidayInstanceId of Array.from(currentIds)) { + if (!requestedIds.has(holidayInstanceId)) { + const deleteResult = await fetchWithBQ({ + url: `${HOLIDAY_GROUP_MEMBERS_PATH}/delete`, + method: 'POST', + body: { + holidayInstanceGroupId: request.id, + holidayInstanceId + } + }); + if (deleteResult.error) { + return { error: deleteResult.error }; + } + } + } + + for (const holidayInstanceId of Array.from(requestedIds)) { + if (!currentIds.has(holidayInstanceId)) { + const addResult = await fetchWithBQ({ + url: `${HOLIDAY_GROUP_MEMBERS_PATH}/add`, + method: 'POST', + body: { + holidayInstanceGroupId: request.id, + holidayInstanceId + } + }); + if (addResult.error) { + return { error: addResult.error }; + } + } + } + + return { data: undefined }; + }, + invalidatesTags: ['HolidayInstanceGroups'] + }), + + deleteHolidayInstanceGroup: builder.mutation({ + async queryFn(holidayInstanceGroupId, _queryApi, _extraOptions, fetchWithBQ) { + const membersResult = await fetchWithBQ({ + url: `${HOLIDAY_GROUP_MEMBERS_PATH}/deleteByGroup`, + method: 'POST', + body: { holidayInstanceGroupId } + }); + if (membersResult.error) { + return { error: membersResult.error }; + } + + const groupResult = await fetchWithBQ({ + url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/delete`, + method: 'POST', + body: { id: holidayInstanceGroupId } + }); + if (groupResult.error) { + return { error: groupResult.error }; + } + + return { data: undefined }; + }, + invalidatesTags: ['HolidayInstanceGroups'] + }) + }) +}); + +export const { + useGetHolidayInstanceGroupsQuery, + useGetHolidayGroupMembersQuery, + useGetHolidayInstanceGroupsWithMembersQuery, + useAddHolidayInstanceGroupMutation, + useEditHolidayInstanceGroupMutation, + useDeleteHolidayInstanceGroupMutation +} = holidayInstanceGroupsApi; diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 68216dbf7e..a097fafbbb 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -291,17 +291,7 @@ const LocaleTranslationData = { "hide": "Hide", "hide.options": "Hide options", "hide.options.in.link": "Hide options in link", - "holiday.day.pattern": "Day pattern", - "holiday.day.pattern.select": "Select a day pattern when holiday rates apply", - "holiday.edit.holiday": "Edit Holiday", - "holiday.end.date": "End Date", - "holiday.name": "Holiday Name", - "holiday.name.required": "Holiday name is required", - "holiday.rates": "Holiday Rates", - "holiday.region": "Region", - "holiday.start.date": "Start Date", - "holiday.start.date.required": "Start date is required", - "holiday.type": "Holiday Type", + "holiday.instance.groups": "Holiday Instance Groups", "home": "Home", "hour": "Hour", "identifier": "Identifier:", @@ -872,17 +862,6 @@ const LocaleTranslationData = { "hide": "Cacher", "hide.options": "Options de quai", "hide.options.in.link": "Hide options in link\u{26A1}", - "holiday.day.pattern": "Modèle de jour", - "holiday.day.pattern.select": "Sélectionnez un modèle de jour lorsque les tarifs de jour férié s'appliquent", - "holiday.edit.holiday": "Modifier le jour férié", - "holiday.end.date": "Date de fin", - "holiday.name": "Nom du jour férié", - "holiday.name.required": "Le nom du jour férié est requis", - "holiday.rates": "Tarifs des jours fériés", - "holiday.region": "Région", - "holiday.start.date": "Date de début", - "holiday.start.date.required": "La date de début est requise", - "holiday.type": "Type de jour férié", "home": "Accueil", "hour": "Hour\u{26A1}", "identifier": "Identifier:\u{26A1}", @@ -1453,17 +1432,6 @@ const LocaleTranslationData = { "hide": "Esconder", "hide.options": "Esconder las opciones", "hide.options.in.link": "Esconder las opciones en el enlace", - "holiday.day.pattern": "Patrón de día", - "holiday.day.pattern.select": "Seleccione un patrón de día cuando se apliquen las tarifas de días festivos", - "holiday.edit.holiday": "Editar día festivo", - "holiday.end.date": "Fecha de finalización", - "holiday.name": "Nombre del día festivo", - "holiday.name.required": "El nombre del día festivo es obligatorio", - "holiday.rates": "Tarifas de días festivos", - "holiday.region": "Región", - "holiday.start.date": "Fecha de inicio", - "holiday.start.date.required": "La fecha de inicio es obligatoria", - "holiday.type": "Tipo de día festivo", "home": "Inicio", "hour": "Hora", "identifier": "Identificador:", From cd6d83d594b0cf0d6e567ad52caef44958f60281 Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Sat, 18 Jul 2026 22:02:20 -0700 Subject: [PATCH 145/170] Add client API slices for holidays and holiday instances - holidaysApi: read-only queries for base holidays (Rose's page owns mutations) - holidayInstancesApi: queries + add/edit/delete mutations matching Hugo's server routes (/api/holidayInstances) - New wire types in types/redux/holidays.ts matching server response shapes - Register Holidays and HolidayInstances tag types in baseApi --- src/client/app/redux/api/baseApi.ts | 4 +- .../app/redux/api/holidayInstancesApi.ts | 87 +++++++++++++++++++ src/client/app/redux/api/holidaysApi.ts | 42 +++++++++ src/client/app/types/redux/holidays.ts | 57 ++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/client/app/redux/api/holidayInstancesApi.ts create mode 100644 src/client/app/redux/api/holidaysApi.ts create mode 100644 src/client/app/types/redux/holidays.ts diff --git a/src/client/app/redux/api/baseApi.ts b/src/client/app/redux/api/baseApi.ts index 633b6f2e53..5b9c91786e 100644 --- a/src/client/app/redux/api/baseApi.ts +++ b/src/client/app/redux/api/baseApi.ts @@ -39,7 +39,9 @@ export const baseApi = createApi({ 'Units', 'Cik', 'Readings', - 'Weeks' + 'Weeks', + 'Holidays', + 'HolidayInstances' ], // Initially no defined endpoints, Use rtk query's injectEndpoints endpoints: () => ({}) diff --git a/src/client/app/redux/api/holidayInstancesApi.ts b/src/client/app/redux/api/holidayInstancesApi.ts new file mode 100644 index 0000000000..203c68368e --- /dev/null +++ b/src/client/app/redux/api/holidayInstancesApi.ts @@ -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 { 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). + * Mirrors src/server/routes/holidayInstances.js: + * - all mutations are POST (OED convention — edit is POST /edit, not PATCH) + * - edit REQUIRES holidayId even though the UI treats the base holiday as + * immutable after create: resend the unchanged value + * - delete of an instance used in a holiday group hits a foreign key and + * returns a database error — the Redux-sweep interlock (meeting 5 to-do) + * should prevent that client-side before calling the mutation + */ +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' }] + }), + getHolidayInstancesByHolidayId: builder.query({ + query: holidayId => `api/holidayInstances/holiday/${holidayId}`, + providesTags: [{ 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, + useGetHolidayInstancesByHolidayIdQuery, + 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..cca7ddc8e1 --- /dev/null +++ b/src/client/app/redux/api/holidaysApi.ts @@ -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 { createSelector } from '@reduxjs/toolkit'; +import { Holiday } from '../../types/redux/holidays'; +import { baseApi } from './baseApi'; + +/* + * Read-only endpoints for the `holidays` table (base holidays fetched from the + * external API via Rose's page). The HolidayInstancePage only reads holidays; + * add/edit/delete endpoints belong to Rose's page and can be injected here + * when her page is wired up. + */ +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 }] + }) + }) +}); + +export const selectHolidaysQueryState = holidaysApi.endpoints.getHolidays.select(); +export const selectAllHolidays = createSelector( + selectHolidaysQueryState, + ({ data: holidays = [] }) => holidays +); + +export const stableEmptyHolidays: Holiday[] = []; + +export const { + useGetHolidaysQuery, + useGetHolidayByIdQuery +} = holidaysApi; diff --git a/src/client/app/types/redux/holidays.ts b/src/client/app/types/redux/holidays.ts new file mode 100644 index 0000000000..677e937ca6 --- /dev/null +++ b/src/client/app/types/redux/holidays.ts @@ -0,0 +1,57 @@ +/* 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/. */ + +/* + * Wire types for the holiday backend (see src/server/routes/holidays.js and + * src/server/routes/holidayInstances.js on the Hugo-Implementing-Holiday-Databases + * branch). Field names match the server's response formatters exactly. + */ + +/** + * A raw holiday row from the `holidays` table (populated via Rose's page). + * 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 for now; may become a region table later (meeting 5) */ + location: 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; +} From 17453d92bc530c521d5d17e89118da1418687ceb Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Sat, 18 Jul 2026 22:04:51 -0700 Subject: [PATCH 146/170] Fix bad auto-merge in ConversionSegment.splitEarlier The merge kept a leftover block from the old instance-method version (const earlierSegment = this) inside Hugo's newer static method, causing a duplicate declaration. The static version already inserts the earlier segment correctly. --- src/server/models/ConversionSegment.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/server/models/ConversionSegment.js b/src/server/models/ConversionSegment.js index 9036b0b419..e1cb20ac9d 100644 --- a/src/server/models/ConversionSegment.js +++ b/src/server/models/ConversionSegment.js @@ -165,10 +165,6 @@ class ConversionSegment { originalEndTime: originalSegment.end_time }); - // earlier segment - insert new - const earlierSegment = this; - await t.none(sqlFile('conversionSegment/insert_new_conversion_segment.sql'), earlierSegment); - }); } From b130c20b24f91a3596c36f27acf29afd8a1efcd3 Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Sun, 19 Jul 2026 00:01:02 -0700 Subject: [PATCH 147/170] Wire HolidayInstancePage to real backend via RTK Query Replace the three DATA SEAM mock hooks with adapter hooks over holidaysApi, daysApi (existing day patterns slice), and holidayInstancesApi. Adapters map internal field names to wire names (instanceName/name, baseHolidayId/holidayId, patternId/dayPatternId). Edit resends the unchanged holidayId since the route requires it. Mutations follow the OED unwrap + success/error notification pattern (strings via STRINGS pending i18n). --- .../holiday-instances/HolidayInstancePage.tsx | 167 ++++++++++-------- 1 file changed, 94 insertions(+), 73 deletions(-) diff --git a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx index 8657b15f95..1409eb0fcf 100644 --- a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx +++ b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx @@ -3,30 +3,34 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { useEffect, useMemo, useState } 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 { titleStyle } from '../../styles/modalStyle'; +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 { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; /* * ------------------------------------------------------------------------- - * DATA SEAM (temporary — replace with real RTK Query hooks when the - * backend routes / API slices exist) - * - * When the backend is ready, delete the mock hooks below and swap in: - * - holidaysApi.useGetHolidaysQuery() -> base holidays for the create dropdown - * - dayPatternsApi.useGetDayPatternsQuery() -> day pattern options - * - holidayInstancesApi.useGetHolidayInstancesQuery() -> existing instances (the cards) - * - holidayInstancesApi.useAddHolidayInstanceMutation() -> create (POST, no id) - * - holidayInstancesApi.useEditHolidayInstanceMutation()-> edit (PATCH, id required) - * - holidayInstancesApi.useDeleteHolidayInstanceMutation() + * DATA SEAM (wired 7/18 — real RTK Query hooks) * - * The instances store AND its mutations live inside this seam - * (useHolidayInstancesMock), so the component body only consumes the hook - * return values — the RTK Query swap really is confined to this block. + * The adapter hooks below consume the real API slices (holidaysApi, daysApi, + * holidayInstancesApi) and expose the same shape the component consumed from + * the old mocks, translating between this file's internal field names and the + * server's wire names (instanceName <-> name, baseHolidayId <-> holidayId, + * patternId <-> dayPatternId). The component body consumes only the hook + * return values, so it did not change in the swap. * * REGION (meeting 4 / 7-2): Steve wants a region selector that auto-populates * the base-holiday dropdown from the selected region's holidays. That is @@ -89,75 +93,86 @@ interface HolidayInstance extends HolidayInstanceData { */ type HolidayInstancePatch = Omit & { id: number }; -// Mock: stands in for holidaysApi.useGetHolidaysQuery() -// Returns the raw holidays Rose's page will eventually save to the `holidays` table. -const useHolidaysMock = () => { - const [data, setData] = useState([]); - const [isFetching, setIsFetching] = useState(true); - - useEffect(() => { - const timer = setTimeout(() => { - setData([ - { id: 1, name: "New Year's Day 2026", date: 'Jan 1, 2026' }, - { id: 2, name: 'Thanksgiving 2026', date: 'Nov 26, 2026' }, - { id: 3, name: 'Independence Day 2026', date: 'Jul 4, 2026' }, - { id: 4, name: 'Labor Day 2026', date: 'Sep 7, 2026' } - ]); - setIsFetching(false); - }, 500); - return () => clearTimeout(timer); - }, []); - - return { data, isFetching }; +// Base holidays from the `holidays` table (populated via Rose's page). +const useHolidays = () => { + const { data = stableEmptyHolidays, isFetching } = useGetHolidaysQuery(); + const mapped = useMemo( + () => data.map(h => ({ id: h.id, name: h.name, date: h.startDate })), + [data] + ); + return { data: mapped, isFetching }; }; -// Mock: stands in for dayPatternsApi.useGetDayPatternsQuery() -// Day patterns already exist in the schema and do NOT depend on Rose's page. -// Exposes isFetching like the real query will, so the page's loading gate -// covers both fetches. -const useDayPatternsMock = () => { - const [data, setData] = useState([]); - const [isFetching, setIsFetching] = useState(true); - - useEffect(() => { - const timer = setTimeout(() => { - setData([ - { id: 101, name: 'Winter Rate' }, - { id: 102, name: 'Summer Rate' }, - { id: 103, name: 'Standard Rate' } - ]); - setIsFetching(false); - }, 300); - return () => clearTimeout(timer); - }, []); - +// Day patterns: OED's existing days slice (`day_patterns` table). Day +// ({ id, name, note }) structurally satisfies DayPatternOption. +const useDayPatterns = (): { data: DayPatternOption[]; isFetching: boolean } => { + const { data = stableEmptyDays, isFetching } = useGetDaysQuery(); return { data, isFetching }; }; -// Mock: stands in for useGetHolidayInstancesQuery() + the three mutations. -// In production each mutation POSTs/PATCHes/DELETEs, then INVALIDATES the -// instances cache tag so the list refetches from the server — global state is -// never written from the local copy (Steve's state model, meeting 4). -const useHolidayInstancesMock = () => { - const [data, setData] = useState([]); +/* + * Instances + mutations. Each mutation invalidates the HolidayInstances cache + * tag, so the list refetches from the server — global state is never written + * from the local copy (Steve's state model, meeting 4). + * TODO(i18n): notification strings move to translate('...') with the rest. + */ +const useHolidayInstances = () => { + const { data = stableEmptyHolidayInstances } = useGetHolidayInstancesQuery(); + const [addMutation] = useAddHolidayInstanceMutation(); + const [editMutation] = useEditHolidayInstanceMutation(); + const [deleteMutation] = useDeleteHolidayInstanceMutation(); + + const instances = useMemo( + () => data.map(i => ({ + id: i.id, + instanceName: i.name, + baseHolidayId: i.holidayId, + patternId: i.dayPatternId, + note: i.note ?? '' + })), + [data] + ); - // PRODUCTION: useAddHolidayInstanceMutation()(draft) — payload has NO id. const addInstance = (draft: HolidayInstanceData) => { - // Date.now() is a mock-only id; the real id comes back from the server. - setData(prev => [...prev, { ...draft, id: Date.now() }]); + addMutation({ + name: draft.instanceName, + holidayId: draft.baseHolidayId, + dayPatternId: draft.patternId, + note: draft.note + }).unwrap() + .then(() => showSuccessNotification(STRINGS.createSuccess)) + .catch(error => showErrorNotification(STRINGS.createFailure + error)); }; - // PRODUCTION: useEditHolidayInstanceMutation()(patch). const editInstance = (patch: HolidayInstancePatch) => { - setData(prev => prev.map(i => (i.id === patch.id ? { ...i, ...patch } : i))); + // The edit route REQUIRES holidayId even though the UI locks the base + // holiday after create — resend the stored (unchanged) value. + const existing = data.find(i => i.id === patch.id); + if (existing === undefined) { + showErrorNotification(STRINGS.editFailure); + return; + } + editMutation({ + id: patch.id, + name: patch.instanceName, + holidayId: existing.holidayId, + dayPatternId: patch.patternId, + note: patch.note + }).unwrap() + .then(() => showSuccessNotification(STRINGS.editSuccess)) + .catch(error => showErrorNotification(STRINGS.editFailure + error)); }; - // PRODUCTION: useDeleteHolidayInstanceMutation()(id). + // TODO(meeting 5): interlock — before deleting, sweep Redux state to check + // the instance is not used in a holiday group; otherwise the foreign key + // makes the server return a database error. const deleteInstance = (id: number) => { - setData(prev => prev.filter(i => i.id !== id)); + deleteMutation({ id }).unwrap() + .then(() => showSuccessNotification(STRINGS.deleteSuccess)) + .catch(error => showErrorNotification(STRINGS.deleteFailure + error)); }; - return { data, addInstance, editInstance, deleteInstance }; + return { data: instances, addInstance, editInstance, deleteInstance }; }; /* End data seam */ @@ -189,7 +204,13 @@ const STRINGS = { saveButton: 'Save Holiday Rate', cancel: 'Cancel', unsavedWarning: 'You have unsaved changes. Are you sure you want to discard them?', - deleteWarning: 'Are you sure you want to delete this holiday rate? This cannot be undone.' + deleteWarning: 'Are you sure you want to delete this holiday rate? This cannot be undone.', + createSuccess: 'Holiday rate created', + createFailure: 'Failed to create holiday rate: ', + editSuccess: 'Holiday rate saved', + editFailure: 'Failed to save holiday rate: ', + deleteSuccess: 'Holiday rate deleted', + deleteFailure: 'Failed to delete holiday rate: ' }; // Sentinel values for "nothing selected yet" dropdown state, mirroring the @@ -250,10 +271,10 @@ function ConfirmModal(props: ConfirmModalProps) { * @returns Holiday Instance page element */ export default function HolidayInstancePage() { - /* Data (mock seam) */ - const { data: holidaysData, isFetching: holidaysFetching } = useHolidaysMock(); - const { data: dayPatterns, isFetching: patternsFetching } = useDayPatternsMock(); - const { data: instances, addInstance, editInstance, deleteInstance } = useHolidayInstancesMock(); + /* Data (real backend via the adapter hooks in the seam above) */ + const { data: holidaysData, isFetching: holidaysFetching } = useHolidays(); + const { data: dayPatterns, isFetching: patternsFetching } = useDayPatterns(); + const { data: instances, addInstance, editInstance, deleteInstance } = useHolidayInstances(); /* * REGION STUB (meeting 4): when the region selector lands, `availableHolidays` From 43a924f1ece7381c67c4a90c87dd20437b671b91 Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Sun, 19 Jul 2026 00:36:16 -0700 Subject: [PATCH 148/170] Move holiday-instances route inside AdminOutlet The page's queries hit admin-only routes; outside the guard the page mounted before token verification and its requests fired without the auth token (403). --- src/client/app/components/RouteComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index 02b85a6c54..20eacac8c1 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -54,11 +54,11 @@ const router = createBrowserRouter([ { path: 'groups', element: }, { path: 'meters', element: }, { path: 'graph', element: }, - { path: 'holiday-instances', element: }, { element: , children: [ { path: 'admin', element: }, + { path: 'holiday-instances', element: }, { path: 'calibration', element: }, { path: 'conversions', element: }, { path: 'days', element: }, From 2dc91e4098155fb4407dcbabb33919c63501060a Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Sun, 19 Jul 2026 00:55:04 -0700 Subject: [PATCH 149/170] Meeting 6 UI to-dos + internationalization - Clip card names at 15 chars (notes stay at 30) - Rename modal field label to 'Holiday Day Name' - Warn (non-blocking, text-warning) when the base holiday changes after a name was entered - Replace STRINGS object with translate() calls via useTranslate; reuse existing keys (cancel, note, discard.changes, unsaved.warning) and add holiday.* / day.pattern.* keys to translations/data.ts for en/fr/es with lightning-bolt placeholders on untranslated entries --- .../holiday-instances/HolidayInstancePage.tsx | 162 ++++++++---------- src/client/app/translations/data.ts | 75 ++++++++ 2 files changed, 150 insertions(+), 87 deletions(-) diff --git a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx index 1409eb0fcf..fdf0d370a5 100644 --- a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx +++ b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx @@ -19,6 +19,7 @@ import { useGetHolidayInstancesQuery } from '../../redux/api/holidayInstancesApi'; import { stableEmptyHolidays, useGetHolidaysQuery } from '../../redux/api/holidaysApi'; +import { useTranslate } from '../../redux/componentHooks'; import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; /* @@ -49,11 +50,10 @@ import { showErrorNotification, showSuccessNotification } from '../../utils/noti * mirrors the Units page: "Holiday Rates" / "Create a Holiday Rate" / * "Details/Edit Holiday Rate". * - * I18N (deferred on purpose): strings are hardcoded English for now so the - * page works without touching translations/data.ts. Before merging upstream, - * swap the STRINGS object values for translate('...') calls — every - * user-facing string routes through STRINGS below, so the refactor is - * confined to that one block. + * I18N (done 7/18): all user-facing strings go through translate('...') with + * keys in translations/data.ts. Generic keys (cancel, note, discard.changes, + * unsaved.warning) are reused per Steve's guidance; new keys live under + * holiday.* / day.pattern.* with lightning-bolt placeholders in fr/es. * ------------------------------------------------------------------------- */ @@ -114,9 +114,9 @@ const useDayPatterns = (): { data: DayPatternOption[]; isFetching: boolean } => * Instances + mutations. Each mutation invalidates the HolidayInstances cache * tag, so the list refetches from the server — global state is never written * from the local copy (Steve's state model, meeting 4). - * TODO(i18n): notification strings move to translate('...') with the rest. */ const useHolidayInstances = () => { + const translate = useTranslate(); const { data = stableEmptyHolidayInstances } = useGetHolidayInstancesQuery(); const [addMutation] = useAddHolidayInstanceMutation(); const [editMutation] = useEditHolidayInstanceMutation(); @@ -140,8 +140,8 @@ const useHolidayInstances = () => { dayPatternId: draft.patternId, note: draft.note }).unwrap() - .then(() => showSuccessNotification(STRINGS.createSuccess)) - .catch(error => showErrorNotification(STRINGS.createFailure + error)); + .then(() => showSuccessNotification(translate('holiday.rate.create.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.create.failure') + error)); }; const editInstance = (patch: HolidayInstancePatch) => { @@ -149,7 +149,7 @@ const useHolidayInstances = () => { // holiday after create — resend the stored (unchanged) value. const existing = data.find(i => i.id === patch.id); if (existing === undefined) { - showErrorNotification(STRINGS.editFailure); + showErrorNotification(translate('holiday.rate.edit.failure')); return; } editMutation({ @@ -159,8 +159,8 @@ const useHolidayInstances = () => { dayPatternId: patch.patternId, note: patch.note }).unwrap() - .then(() => showSuccessNotification(STRINGS.editSuccess)) - .catch(error => showErrorNotification(STRINGS.editFailure + error)); + .then(() => showSuccessNotification(translate('holiday.rate.edit.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.edit.failure') + error)); }; // TODO(meeting 5): interlock — before deleting, sweep Redux state to check @@ -168,8 +168,8 @@ const useHolidayInstances = () => { // makes the server return a database error. const deleteInstance = (id: number) => { deleteMutation({ id }).unwrap() - .then(() => showSuccessNotification(STRINGS.deleteSuccess)) - .catch(error => showErrorNotification(STRINGS.deleteFailure + error)); + .then(() => showSuccessNotification(translate('holiday.rate.delete.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.delete.failure') + error)); }; return { data: instances, addInstance, editInstance, deleteInstance }; @@ -177,51 +177,20 @@ const useHolidayInstances = () => { /* End data seam */ -/* - * All user-facing text, in one place. TODO(i18n): replace each value with the - * matching translate('...') call and add the keys to translations/data.ts — - * nothing outside this object needs to change. - */ -const STRINGS = { - pageTitle: 'Holiday Rates', - createButton: 'Create a Holiday Rate', - detailsButton: 'Details/Edit Holiday Rate', - noRates: 'No holiday rates have been created yet.', - loading: 'Loading holidays...', - baseHoliday: 'Base Holiday:', - baseHolidaySelect: 'Select or search for a holiday...', - baseHolidayRequired: 'Base holiday is required', - baseHolidayUnknown: '(unknown holiday)', - rateName: 'Holiday Rate Name:', - nameRequired: 'Name is required', - dayPattern: 'Day Pattern:', - dayPatternSelect: 'Select a day pattern', - dayPatternRequired: 'Day pattern is required', - note: 'Note:', - deleteButton: 'Delete Holiday Rate', - discardButton: 'Discard Changes', - createSaveButton: 'Create Holiday Rate', - saveButton: 'Save Holiday Rate', - cancel: 'Cancel', - unsavedWarning: 'You have unsaved changes. Are you sure you want to discard them?', - deleteWarning: 'Are you sure you want to delete this holiday rate? This cannot be undone.', - createSuccess: 'Holiday rate created', - createFailure: 'Failed to create holiday rate: ', - editSuccess: 'Holiday rate saved', - editFailure: 'Failed to save holiday rate: ', - deleteSuccess: 'Holiday rate deleted', - deleteFailure: 'Failed to delete holiday rate: ' -}; - // Sentinel values for "nothing selected yet" dropdown state, mirroring the // -999 placeholder convention used in the conversion modals. const NO_HOLIDAY = -999; const NO_PATTERN = -999; -// Cards truncate long names at 30 chars, matching the conversion note/log convention. -const MAX_NAME_LENGTH = 30; -const truncate = (text: string) => - text.length > MAX_NAME_LENGTH ? `${text.slice(0, MAX_NAME_LENGTH)} ...` : text; +/* + * Card clipping (meeting 6): names clip at 15 chars (Steve: put the critical + * information in the first characters); notes keep the conversion note/log + * convention of 30. + */ +const MAX_NAME_LENGTH = 15; +const MAX_NOTE_LENGTH = 30; +const clip = (text: string, max: number) => + text.length > max ? `${text.slice(0, max)} ...` : text; // Default (empty) create draft. Discard / open-create resets to this. const EMPTY_DRAFT: HolidayInstanceData = { @@ -248,13 +217,18 @@ interface ConfirmModalProps { onCancel: () => void; } +/** + * @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}
@@ -452,30 +431,30 @@ export default function HolidayInstancePage() { {instances.map(instance => (
- {truncate(instance.instanceName)} + {clip(instance.instanceName, MAX_NAME_LENGTH)}
{/* Bold "Label:" + value lines, like the Units cards. */}
- {STRINGS.baseHoliday} {holidayName(instance.baseHolidayId)} + {translate('holiday.base')} {holidayName(instance.baseHolidayId)}
- {STRINGS.dayPattern} {patternName(instance.patternId)} + {translate('day.pattern')} {patternName(instance.patternId)}
{instance.note.trim().length > 0 && (
- {STRINGS.note} {truncate(instance.note)} + {translate('note')} {clip(instance.note, MAX_NOTE_LENGTH)}
)} {/* Mirrors the Units card's "Details/Edit Unit" button. */}
))} {instances.length === 0 && ( -

{STRINGS.noRates}

+

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

)}
@@ -484,7 +463,7 @@ export default function HolidayInstancePage() { {/* Headers mirror Units: "Create a Holiday Rate" / "Details/Edit Holiday Rate". */} - {modalMode === 'create' ? STRINGS.createButton : STRINGS.detailsButton} + {modalMode === 'create' ? translate('holiday.rate.create') : translate('holiday.rate.edit')} @@ -498,7 +477,7 @@ export default function HolidayInstancePage() { holiday effectively invalidates the instance, so it can't be edited in place; delete + create a new one instead. */} - + {modalMode === 'create' ? ( <> - {STRINGS.nameRequired} + {translate('holiday.rate.name.required')} @@ -559,7 +547,7 @@ export default function HolidayInstancePage() { {/* Day pattern — required. Placeholder option mirrors the conversion modal's hidden/disabled -999 sentinel option. */} - + - {STRINGS.dayPatternSelect} + {translate('day.pattern.select')} {dayPatterns.map(pattern => ( ))} - {STRINGS.dayPatternRequired} + {translate('day.pattern.required')} {/* Note — optional */} - + setShowDeleteConfirm(true)} style={{ marginRight: 'auto' }} > - {STRINGS.deleteButton} + {translate('holiday.rate.delete')} )} @@ -626,8 +614,8 @@ export default function HolidayInstancePage() { {/* Unsaved-changes warning (meeting 4: "you're about to lose your work"). */} setShowCloseConfirm(false)} @@ -636,8 +624,8 @@ export default function HolidayInstancePage() { {/* Delete guard. */} setShowDeleteConfirm(false)} diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 662b742a0a..4de75ad134 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -194,6 +194,9 @@ const LocaleTranslationData = { "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?", @@ -377,6 +380,28 @@ 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.day.name": "Holiday Day Name:", + "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.loading": "Loading holidays...", + "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", "home": "Home", "hour": "Hour", "identifier": "Identifier:", @@ -875,6 +900,9 @@ const LocaleTranslationData = { "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?", @@ -1058,6 +1086,28 @@ 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.day.name": "Holiday Day Name:\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.loading": "Loading holidays...\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}", "home": "Accueil", "hour": "Hour\u{26A1}", "identifier": "Identifier:\u{26A1}", @@ -1556,6 +1606,9 @@ const LocaleTranslationData = { "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?", @@ -1739,6 +1792,28 @@ 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.day.name": "Holiday Day Name:\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.loading": "Loading holidays...\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}", "home": "Inicio", "hour": "Hora", "identifier": "Identificador:", From 88d4aa218faeaefabca2ec935f9756533aba599f Mon Sep 17 00:00:00 2001 From: Rose Date: Wed, 22 Jul 2026 12:25:24 -0700 Subject: [PATCH 150/170] Added the back end holiday routes location and refresh endpoints Used date-holidays to load reginal holiday to the db and made them duplicate safe. --- package-lock.json | 151 ++++++++++++++- package.json | 1 + src/server/models/Holiday.js | 12 ++ src/server/routes/holidays.js | 183 ++++++++++++++++++ .../sql/holiday/insert_or_update_holiday.sql | 18 ++ 5 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 src/server/sql/holiday/insert_or_update_holiday.sql 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/server/models/Holiday.js b/src/server/models/Holiday.js index a3c2d1f758..3ba103e9af 100644 --- a/src/server/models/Holiday.js +++ b/src/server/models/Holiday.js @@ -85,6 +85,18 @@ class Holiday{ 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 diff --git a/src/server/routes/holidays.js b/src/server/routes/holidays.js index 8bf6e9c6a8..1aeec39f7f 100644 --- a/src/server/routes/holidays.js +++ b/src/server/routes/holidays.js @@ -6,12 +6,63 @@ 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.code.localeCompare(second.code)); +} + +/** + * 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); + + //TODO: may be good to implement some checks here ie date format or if data came back empty + //TODO: remove comments which start with Rose: + //Rose: undefined is id, and null is the note. + return new Holiday( + undefined, + name, + startDate, + location, + null + ); +} + function formatHolidayForResponse(item) { return { id: item.id, @@ -38,6 +89,138 @@ router.get('/', adminAuthMiddleware('get all holidays'), async (req, res) => { } }); +/** + * 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) { + log.error(`Error while getting holiday locations: ${err}`); + res.sendStatus(500); + } + } +}); + +/** + * 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) { + log.error(`Error while refreshing holidays: ${err}`); + res.sendStatus(500); + } + } +}); + /** * Route for getting one holiday by 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..91f5b7e68a --- /dev/null +++ b/src/server/sql/holiday/insert_or_update_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, + note +) VALUES ( + ${name}, + ${startDate}, + ${location}, + ${note} +) +ON CONFLICT (name, location, start_date) + DO UPDATE SET + note = COALESCE(EXCLUDED.note, holidays.note); \ No newline at end of file From 8bbed1a76843ce94b6373ef0f03f66270b472538 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 22 Jul 2026 14:28:05 -0700 Subject: [PATCH 151/170] added all formattedMessages to data.ts and added translations --- ...eateHolidayInstanceGroupModalComponent.tsx | 17 +++++------- ...EditHolidayInstanceGroupModalComponent.tsx | 17 +++++------- .../holiday/HolidayInstanceGroupComponent.tsx | 3 +-- .../HolidayInstanceGroupViewComponent.jsx | 26 ++++++------------- src/client/app/translations/data.ts | 25 ++++++++++++++++++ 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx index 154607c69a..adf7bf2628 100644 --- a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx @@ -85,7 +85,6 @@ export default function CreateHolidayInstanceGroupModalComponent( @@ -93,14 +92,13 @@ export default function CreateHolidayInstanceGroupModalComponent( - + @@ -123,7 +121,6 @@ export default function CreateHolidayInstanceGroupModalComponent( {holidayInstanceIds.length === 0 && ( - + )} @@ -149,7 +146,6 @@ export default function CreateHolidayInstanceGroupModalComponent( @@ -172,7 +167,7 @@ export default function CreateHolidayInstanceGroupModalComponent( diff --git a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx index 1607631e08..a1d3c1d379 100644 --- a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx @@ -137,14 +137,13 @@ export default function EditHolidayInstanceGroupModalComponent( - + @@ -167,7 +166,6 @@ export default function EditHolidayInstanceGroupModalComponent( {holidayInstanceIds.length === 0 && ( - + )} @@ -193,7 +191,6 @@ export default function EditHolidayInstanceGroupModalComponent( @@ -216,7 +212,7 @@ export default function EditHolidayInstanceGroupModalComponent( )} diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index 463f87de8b..e6dffa5cdf 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -93,8 +93,7 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou

diff --git a/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx index 2f711abcfb..931ef86a5a 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupViewComponent.jsx @@ -48,15 +48,14 @@ export default function HolidayInstanceGroupViewComponent(props) { {selectedHolidayInstances.length > 0 ? ( -
    - {selectedHolidayInstances.map(holidayInstance => ( -
  • {holidayInstance.name}
  • - ))} -
+ + {selectedHolidayInstances[0].name} + {selectedHolidayInstances.length > 1 && + `...(${selectedHolidayInstances.length})`} + ) : ( None )} @@ -64,24 +63,22 @@ export default function HolidayInstanceGroupViewComponent(props) {
- + {' '}
- {' '} + {' '} {displayedNote}
); } - -const holidayInstanceListStyle = { - margin: '0.5rem 0 0', - maxHeight: '8rem', - overflowY: 'auto', - paddingLeft: '1.25rem' -}; diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index a097fafbbb..ecd718b1a2 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -291,7 +291,16 @@ const LocaleTranslationData = { "hide": "Hide", "hide.options": "Hide options", "hide.options.in.link": "Hide options in link", + + "holiday.instance.group.create": "Create Holiday Instance Group", + "holiday.instance.group.delete": "Delete Holiday Instance Group", + "holiday.instance.group.details": "Details/Edit Group", + "holiday.instance.group.edit": "Edit Holiday Instance Group", "holiday.instance.groups": "Holiday Instance Groups", + "holiday.instances": "Holiday Instances: ", + "holiday.region": "Holiday Region: ", + "holiday.region.unavailable": "Unavailable", + "home": "Home", "hour": "Hour", "identifier": "Identifier:", @@ -862,6 +871,14 @@ const LocaleTranslationData = { "hide": "Cacher", "hide.options": "Options de quai", "hide.options.in.link": "Hide options in link\u{26A1}", + "holiday.instance.group.create": "Créer un groupe d'instances de jours fériés", + "holiday.instance.group.delete": "Supprimer le groupe d'instances de jours fériés", + "holiday.instance.group.details": "Détails/Modifier le groupe", + "holiday.instance.group.edit": "Modifier le groupe d'instances de jours fériés", + "holiday.instance.groups": "Groupes d'instances de jours fériés", + "holiday.instances": "Instances de jours fériés : ", + "holiday.region": "Région des jours fériés : ", + "holiday.region.unavailable": "Indisponible", "home": "Accueil", "hour": "Hour\u{26A1}", "identifier": "Identifier:\u{26A1}", @@ -1432,6 +1449,14 @@ const LocaleTranslationData = { "hide": "Esconder", "hide.options": "Esconder las opciones", "hide.options.in.link": "Esconder las opciones en el enlace", + "holiday.instance.group.create": "Crear grupo de instancias de días festivos", + "holiday.instance.group.delete": "Eliminar grupo de instancias de días festivos", + "holiday.instance.group.details": "Detalles/Editar grupo", + "holiday.instance.group.edit": "Editar grupo de instancias de días festivos", + "holiday.instance.groups": "Grupos de instancias de días festivos", + "holiday.instances": "Instancias de días festivos: ", + "holiday.region": "Región de días festivos: ", + "holiday.region.unavailable": "No disponible", "home": "Inicio", "hour": "Hora", "identifier": "Identificador:", From 17207509768480090da62f8125ace745d39482f8 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 22 Jul 2026 15:29:40 -0700 Subject: [PATCH 152/170] changed holiday instances to holiday rates. Edited all translations to say the same --- src/client/app/translations/data.ts | 50 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index df79e3e57c..b9405c623b 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -380,22 +380,18 @@ const LocaleTranslationData = { "hide": "Hide", "hide.options": "Hide options", "hide.options.in.link": "Hide options in link", - - "holiday.instance.group.create": "Create Holiday Instance Group", - "holiday.instance.group.delete": "Delete Holiday Instance Group", - "holiday.instance.group.details": "Details/Edit Group", - "holiday.instance.group.edit": "Edit Holiday Instance Group", - "holiday.instance.groups": "Holiday Instance Groups", - "holiday.instances": "Holiday Instances: ", - "holiday.region": "Holiday Region: ", - "holiday.region.unavailable": "Unavailable", - "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.day.name": "Holiday Day Name:", + "holiday.instance.group.create": "Create Holiday Rate Group", + "holiday.instance.group.delete": "Delete Holiday Rate Group", + "holiday.instance.group.details": "Details/Edit Group", + "holiday.instance.group.edit": "Edit Holiday Rate Group", + "holiday.instance.groups": "Holiday Rate Groups", + "holiday.instances": "Holiday Rates: ", "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", @@ -412,6 +408,8 @@ const LocaleTranslationData = { "holiday.rate.none": "No holiday rates have been created yet.", "holiday.rate.save": "Save Holiday Rate", "holiday.rates": "Holiday Rates", + "holiday.region": "Holiday Region: ", + "holiday.region.unavailable": "Unavailable", "home": "Home", "hour": "Hour", "identifier": "Identifier:", @@ -1096,20 +1094,18 @@ const LocaleTranslationData = { "hide": "Cacher", "hide.options": "Options de quai", "hide.options.in.link": "Hide options in link\u{26A1}", - "holiday.instance.group.create": "Créer un groupe d'instances de jours fériés", - "holiday.instance.group.delete": "Supprimer le groupe d'instances de jours fériés", - "holiday.instance.group.details": "Détails/Modifier le groupe", - "holiday.instance.group.edit": "Modifier le groupe d'instances de jours fériés", - "holiday.instance.groups": "Groupes d'instances de jours fériés", - "holiday.instances": "Instances de jours fériés : ", - "holiday.region": "Région des jours fériés : ", - "holiday.region.unavailable": "Indisponible", "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.day.name": "Holiday Day Name:\u{26A1}", + "holiday.instance.group.create": "Créer un groupe de tarifs de jours fériés", + "holiday.instance.group.delete": "Supprimer le groupe de tarifs de jours fériés", + "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.groups": "Groupes de tarifs de jours fériés", + "holiday.instances": "Tarifs de jours fériés : ", "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}", @@ -1126,6 +1122,8 @@ const LocaleTranslationData = { "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": "Région des jours fériés : ", + "holiday.region.unavailable": "Indisponible", "home": "Accueil", "hour": "Hour\u{26A1}", "identifier": "Identifier:\u{26A1}", @@ -1810,20 +1808,18 @@ const LocaleTranslationData = { "hide": "Esconder", "hide.options": "Esconder las opciones", "hide.options.in.link": "Esconder las opciones en el enlace", - "holiday.instance.group.create": "Crear grupo de instancias de días festivos", - "holiday.instance.group.delete": "Eliminar grupo de instancias de días festivos", - "holiday.instance.group.details": "Detalles/Editar grupo", - "holiday.instance.group.edit": "Editar grupo de instancias de días festivos", - "holiday.instance.groups": "Grupos de instancias de días festivos", - "holiday.instances": "Instancias de días festivos: ", - "holiday.region": "Región de días festivos: ", - "holiday.region.unavailable": "No disponible", "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.day.name": "Holiday Day Name:\u{26A1}", + "holiday.instance.group.create": "Crear grupo de tarifas de días festivos", + "holiday.instance.group.delete": "Eliminar grupo de tarifas de días festivos", + "holiday.instance.group.details": "Detalles/Editar grupo", + "holiday.instance.group.edit": "Editar grupo de tarifas de días festivos", + "holiday.instance.groups": "Grupos de tarifas de días festivos", + "holiday.instances": "Tarifas de días festivos: ", "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}", @@ -1840,6 +1836,8 @@ const LocaleTranslationData = { "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": "Región de días festivos: ", + "holiday.region.unavailable": "No disponible", "home": "Inicio", "hour": "Hora", "identifier": "Identificador:", From e577db0c5b78101b9e7031742e7f155b573faa2c Mon Sep 17 00:00:00 2001 From: Rose Date: Wed, 22 Jul 2026 15:40:02 -0700 Subject: [PATCH 153/170] Implemented holiday page front end and connected it to the backend --- .../app/components/HeaderButtonsComponent.tsx | 9 + src/client/app/components/RouteComponent.tsx | 3 + .../holiday-instances/HolidayInstancePage.tsx | 28 +- .../app/components/holiday/HolidayPage.tsx | 285 ++++++++++++++++++ .../holiday/HolidayViewComponent.tsx | 39 +++ src/client/app/redux/api/holidaysApi.ts | 51 +++- src/client/app/translations/data.ts | 75 +++++ src/client/app/types/redux/holidays.ts | 45 +++ .../sql/holiday/create_holidays_table.sql | 1 + src/server/models/Holiday.js | 10 +- src/server/routes/holidays.js | 18 +- .../sql/holiday/create_holidays_table.sql | 1 + src/server/sql/holiday/insert_new_holiday.sql | 2 + .../sql/holiday/insert_or_update_holiday.sql | 5 +- 14 files changed, 549 insertions(+), 23 deletions(-) create mode 100644 src/client/app/components/holiday/HolidayPage.tsx diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index f932317af2..3a5db75122 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -65,6 +65,7 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, shouldDaysButtonDisabled: true, + shouldHolidaysButtonDisabled: true, shouldWeeksButtonDisabled: true, shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, @@ -105,6 +106,7 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', shouldDaysButtonDisabled: pathname === '/days', + shouldHolidaysButtonDisabled: pathname === '/holidays', shouldWeeksButtonDisabled: pathname === '/weeks', shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' @@ -215,6 +217,13 @@ export default function HeaderButtonsComponent() { to="/groups"> + + + , children: [ { path: 'admin', element: }, + { path: 'holidays', element: }, { path: 'holiday-instances', element: }, { path: 'calibration', element: }, { path: 'conversions', element: }, diff --git a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx index fdf0d370a5..e703012762 100644 --- a/src/client/app/components/holiday-instances/HolidayInstancePage.tsx +++ b/src/client/app/components/holiday-instances/HolidayInstancePage.tsx @@ -60,7 +60,7 @@ import { showErrorNotification, showSuccessNotification } from '../../utils/noti interface BaseHoliday { id: number; name: string; - date: string; + location: string; } interface DayPatternOption { @@ -97,7 +97,11 @@ type HolidayInstancePatch = Omit & { id: n const useHolidays = () => { const { data = stableEmptyHolidays, isFetching } = useGetHolidaysQuery(); const mapped = useMemo( - () => data.map(h => ({ id: h.id, name: h.name, date: h.startDate })), + () => data.map(holiday => ({ + id: holiday.id, + name: holiday.name, + location: holiday.location + })), [data] ); return { data: mapped, isFetching }; @@ -265,6 +269,10 @@ export default function HolidayInstancePage() { [holidaysData] ); + const holidayLabel = (holiday: BaseHoliday) => { + return `${holiday.name} (${holiday.location})`; + }; + /* ---- Local (modal) state — untied to global state until Save ---- */ const [showModal, setShowModal] = useState(false); const [modalMode, setModalMode] = useState('create'); @@ -287,8 +295,15 @@ export default function HolidayInstancePage() { // instances query should return the joined holiday name (or names should be // fetched by id) — until then, fall back to a visible placeholder rather // than rendering blank. - const holidayName = (id: number) => - availableHolidays.find(h => h.id === id)?.name ?? translate('holiday.base.unknown'); + const holidayName = (id: number) => { + const holiday = availableHolidays.find(item => item.id === id); + + if (holiday === undefined) { + return translate('holiday.base.unknown'); + } + + return holidayLabel(holiday); + }; const patternName = (id: number) => dayPatterns.find(p => p.id === id)?.name ?? ''; @@ -300,7 +315,10 @@ export default function HolidayInstancePage() { * TODO(repo): if OED's shared SingleSelectComponent fits, swap to it. */ const holidayOptions = useMemo( - () => availableHolidays.map(h => ({ value: h.id, label: h.name })), + () => availableHolidays.map(holiday => ({ + value: holiday.id, + label: holidayLabel(holiday) + })), [availableHolidays] ); const selectedHolidayOption = diff --git a/src/client/app/components/holiday/HolidayPage.tsx b/src/client/app/components/holiday/HolidayPage.tsx new file mode 100644 index 0000000000..d77b801e11 --- /dev/null +++ b/src/client/app/components/holiday/HolidayPage.tsx @@ -0,0 +1,285 @@ +/* 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, FormGroup, Input, Label, Row } from 'reactstrap'; +import { + stableEmptyHolidays, + useGetHolidaysQuery, + useGetHolidayLocationsQuery, + useRefreshHolidaysMutation +} from '../../redux/api/holidaysApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { titleStyle } from '../../styles/modalStyle'; +import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +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()) + ); + const [refreshHolidays, { isLoading }] = useRefreshHolidaysMutation(); + const { data: holidays = stableEmptyHolidays } = useGetHolidaysQuery(); + + const { data: locationData } = useGetHolidayLocationsQuery({ + country: countryCode, + state: stateCode + }); + + 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]); + + 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]); + + 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); + }; + + const handleImport = () => { + if (countryCode.length === 0) { + showErrorNotification(translate('holiday.import.country.required')); + return; + } + + if (year.trim().length === 0) { + showErrorNotification(translate('holiday.year.invalid')); + return; + } + + const selectedYear = Number(year); + + if (!Number.isInteger(selectedYear)) { + showErrorNotification(translate('holiday.year.invalid')); + return; + } + + 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}`); + }); + }; + + 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 index e69de29bb2..1e08ab07d2 100644 --- a/src/client/app/components/holiday/HolidayViewComponent.tsx +++ 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/redux/api/holidaysApi.ts b/src/client/app/redux/api/holidaysApi.ts index cca7ddc8e1..1eb845ecae 100644 --- a/src/client/app/redux/api/holidaysApi.ts +++ b/src/client/app/redux/api/holidaysApi.ts @@ -3,14 +3,18 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { createSelector } from '@reduxjs/toolkit'; -import { Holiday } from '../../types/redux/holidays'; +import { + Holiday, + HolidayLocations, + HolidayLocationsQuery, + RefreshHolidaysRequest, + RefreshHolidaysResponse +} from '../../types/redux/holidays'; import { baseApi } from './baseApi'; /* - * Read-only endpoints for the `holidays` table (base holidays fetched from the - * external API via Rose's page). The HolidayInstancePage only reads holidays; - * add/edit/delete endpoints belong to Rose's page and can be injected here - * when her page is wired up. + * 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 => ({ @@ -24,6 +28,37 @@ export const holidaysApi = baseApi.injectEndpoints({ 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' }] }) }) }); @@ -38,5 +73,7 @@ export const stableEmptyHolidays: Holiday[] = []; export const { useGetHolidaysQuery, - useGetHolidayByIdQuery -} = holidaysApi; + useGetHolidayByIdQuery, + useGetHolidayLocationsQuery, + useRefreshHolidaysMutation +} = holidaysApi; \ No newline at end of file diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 4de75ad134..4e62758c52 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -385,7 +385,17 @@ const LocaleTranslationData = { "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.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", @@ -402,6 +412,21 @@ const LocaleTranslationData = { "holiday.rate.none": "No holiday rates have been created yet.", "holiday.rate.save": "Save Holiday Rate", "holiday.rates": "Holiday Rates", + "holiday.region": "Region:", + "holiday.region.select": "Select a region", + "holiday.saved": "Saved Holidays", + "holiday.state": "State/Province:", + "holiday.state.select": "Select a 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:", @@ -1091,7 +1116,17 @@ const LocaleTranslationData = { "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.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}", @@ -1108,6 +1143,21 @@ const LocaleTranslationData = { "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.select": "Select a region\u{26A1}", + "holiday.saved": "Saved Holidays\u{26A1}", + "holiday.state": "State/Province:\u{26A1}", + "holiday.state.select": "Select a 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}", @@ -1797,7 +1847,17 @@ const LocaleTranslationData = { "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.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}", @@ -1814,6 +1874,21 @@ const LocaleTranslationData = { "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.select": "Select a region\u{26A1}", + "holiday.saved": "Saved Holidays\u{26A1}", + "holiday.state": "State/Province:\u{26A1}", + "holiday.state.select": "Select a 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:", diff --git a/src/client/app/types/redux/holidays.ts b/src/client/app/types/redux/holidays.ts index 677e937ca6..882a3ce70d 100644 --- a/src/client/app/types/redux/holidays.ts +++ b/src/client/app/types/redux/holidays.ts @@ -19,6 +19,7 @@ export interface Holiday { startDate: string; /** Free-text location for now; may become a region table later (meeting 5) */ location: string; + type: string; note?: string | null; } @@ -55,3 +56,47 @@ export interface HolidayInstanceDetails extends HolidayInstance { 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/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 index 1d2077a5dc..e327e3f69c 100644 --- 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 @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS holidays ( 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/models/Holiday.js b/src/server/models/Holiday.js index 3ba103e9af..0d6a5c597b 100644 --- a/src/server/models/Holiday.js +++ b/src/server/models/Holiday.js @@ -12,13 +12,15 @@ class 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 = '') { + 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; } /** @@ -47,7 +49,7 @@ class Holiday{ */ 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)); + return rows.map(row => new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type)); } /** @@ -58,7 +60,7 @@ class Holiday{ */ 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); + return new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type); } /** @@ -69,7 +71,7 @@ class Holiday{ */ 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)); + return rows.map(row => new Holiday(row.id, row.name, row.start_date, row.location, row.note, row.type)); } /** diff --git a/src/server/routes/holidays.js b/src/server/routes/holidays.js index 1aeec39f7f..930a984e8c 100644 --- a/src/server/routes/holidays.js +++ b/src/server/routes/holidays.js @@ -35,7 +35,7 @@ function buildLocationCode(country, state = '', region = ''){ function formatLocationOptions(locations) { return Object.entries(locations || {}) .map(([code, name]) => ({ code, name })) - .sort((first, second) => first.code.localeCompare(second.code)); + .sort((first, second) => first.name.localeCompare(second.name)); } /** @@ -50,25 +50,31 @@ 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(); - //TODO: may be good to implement some checks here ie date format or if data came back empty - //TODO: remove comments which start with Rose: - //Rose: undefined is id, and null is the note. return new Holiday( undefined, name, startDate, location, - null + null, + type ); } function formatHolidayForResponse(item) { + let startDate = item.startDate; + + if (startDate instanceof Date) { + startDate = startDate.toISOString(); + } + return { id: item.id, name: item.name, - startDate: item.startDate, + startDate: String(startDate).slice(0, 10), location: item.location, + type: item.type, note: item.note }; } diff --git a/src/server/sql/holiday/create_holidays_table.sql b/src/server/sql/holiday/create_holidays_table.sql index 1d2077a5dc..e327e3f69c 100644 --- a/src/server/sql/holiday/create_holidays_table.sql +++ b/src/server/sql/holiday/create_holidays_table.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS holidays ( 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/insert_new_holiday.sql b/src/server/sql/holiday/insert_new_holiday.sql index 670b29630f..fc3c16426d 100644 --- a/src/server/sql/holiday/insert_new_holiday.sql +++ b/src/server/sql/holiday/insert_new_holiday.sql @@ -6,11 +6,13 @@ 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 index 91f5b7e68a..7577034537 100644 --- a/src/server/sql/holiday/insert_or_update_holiday.sql +++ b/src/server/sql/holiday/insert_or_update_holiday.sql @@ -6,13 +6,16 @@ INSERT INTO holidays ( name, start_date, location, + type, note ) VALUES ( ${name}, ${startDate}, ${location}, + ${type}, ${note} ) ON CONFLICT (name, location, start_date) DO UPDATE SET - note = COALESCE(EXCLUDED.note, holidays.note); \ No newline at end of file + type = EXCLUDED.type, + note = COALESCE(EXCLUDED.note, holidays.note); From dbcb59e0c99a9528203fee4058b40a189e6eb331 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 22 Jul 2026 16:18:42 -0700 Subject: [PATCH 154/170] Created Holiday Instance Group API and edited various files to implement it. Load and saving data to database --- ...eateHolidayInstanceGroupModalComponent.tsx | 2 +- ...EditHolidayInstanceGroupModalComponent.tsx | 11 +- .../holiday/HolidayInstanceGroupComponent.tsx | 128 +++++----- src/client/app/redux/api/baseApi.ts | 3 +- .../api/holidayInstanceGroupsApi.temp.ts | 235 ------------------ .../app/redux/api/holidayInstanceGroupsApi.ts | 234 +++++++++++++++++ src/client/app/types/redux/holidays.ts | 28 +++ 7 files changed, 334 insertions(+), 307 deletions(-) delete mode 100644 src/client/app/redux/api/holidayInstanceGroupsApi.temp.ts create mode 100644 src/client/app/redux/api/holidayInstanceGroupsApi.ts diff --git a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx index adf7bf2628..659b3e45c4 100644 --- a/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/CreateHolidayInstanceGroupModalComponent.tsx @@ -21,7 +21,7 @@ import { } from 'reactstrap'; import MultiSelectComponent from '../MultiSelectComponent'; import { SelectOption } from '../../types/items'; -import { HolidayInstance } from '../../types/redux/holiday'; +import { HolidayInstance } from '../../types/redux/holidays'; interface CreateHolidayInstanceGroupModalComponentProps { holidayInstances: HolidayInstance[]; diff --git a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx index a1d3c1d379..0767597062 100644 --- a/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday/EditHolidayInstanceGroupModalComponent.tsx @@ -22,18 +22,11 @@ import { import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import MultiSelectComponent from '../MultiSelectComponent'; import { SelectOption } from '../../types/items'; -import { HolidayInstance } from '../../types/redux/holiday'; - -export interface HolidayInstanceGroupData { - id: number; - name: string; - holidayInstanceIds: number[]; - note: string; -} +import { HolidayInstance, HolidayInstanceGroup } from '../../types/redux/holidays'; interface EditHolidayInstanceGroupModalComponentProps { show: boolean; - holidayInstanceGroup: HolidayInstanceGroupData; + holidayInstanceGroup: HolidayInstanceGroup; holidayInstances: HolidayInstance[]; handleShow: () => void; handleClose: () => void; diff --git a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx index e6dffa5cdf..10d0372436 100644 --- a/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday/HolidayInstanceGroupComponent.tsx @@ -5,89 +5,85 @@ import * as React from 'react'; import { useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import { + stableEmptyHolidayInstances, + useGetHolidayInstancesQuery +} from '../../redux/api/holidayInstancesApi'; +import { + stableEmptyHolidayInstanceGroups, + useAddHolidayInstanceGroupMutation, + useDeleteHolidayInstanceGroupMutation, + useEditHolidayInstanceGroupMutation, + useGetHolidayInstanceGroupsQuery +} from '../../redux/api/holidayInstanceGroupsApi'; import { titleStyle } from '../../styles/modalStyle'; -import { HolidayInstance } from '../../types/redux/holiday'; import CreateHolidayInstanceGroupModalComponent from './CreateHolidayInstanceGroupModalComponent'; -import { HolidayInstanceGroupData } from './EditHolidayInstanceGroupModalComponent'; import HolidayInstanceGroupViewComponent from './HolidayInstanceGroupViewComponent'; -import { testHolidayInstances } from './holidayInstanceTestData'; // For testing purposes, remove in production. - -interface HolidayInstanceGroupComponentProps { - holidayInstances?: HolidayInstance[]; - holidayInstanceGroups?: HolidayInstanceGroupData[]; - handleCreateHolidayInstanceGroup?: (name: string, holidayInstanceIds: number[], note: string) => void; - handleUpdateHolidayInstanceGroup?: ( - holidayInstanceGroupId: number, - name: string, - holidayInstanceIds: number[], - note: string - ) => void; - handleDeleteHolidayInstanceGroup?: (holidayInstanceGroupId: number) => void; -} /** * Defines the holiday instance group card page. - * @param props Holiday instance data, group data, and optional action handlers. * @returns Holiday instance group page element. */ -export default function HolidayInstanceGroupComponent(props: HolidayInstanceGroupComponentProps) { - const holidayInstances = props.holidayInstances ?? testHolidayInstances; - const [createdHolidayInstanceGroups, setCreatedHolidayInstanceGroups] = useState([]); - const holidayInstanceGroups = props.holidayInstanceGroups ?? createdHolidayInstanceGroups; - const usingLocalHolidayInstanceGroups = props.holidayInstanceGroups === undefined; - - const handleCreateHolidayInstanceGroup = (name: string, holidayInstanceIds: number[], note: string) => { - props.handleCreateHolidayInstanceGroup?.(name, holidayInstanceIds, note); - - if (usingLocalHolidayInstanceGroups) { - setCreatedHolidayInstanceGroups(currentGroups => { - const nextId = Math.max(0, ...currentGroups.map(group => group.id)) + 1; +export default function HolidayInstanceGroupComponent() { + const [mutationError, setMutationError] = useState(''); + const { + data: holidayInstances = stableEmptyHolidayInstances, + isLoading: holidayInstancesLoading, + error: holidayInstancesError + } = useGetHolidayInstancesQuery(); + const { + data: holidayInstanceGroups = stableEmptyHolidayInstanceGroups, + isLoading: holidayInstanceGroupsLoading, + error: holidayInstanceGroupsError + } = useGetHolidayInstanceGroupsQuery(); + const [addHolidayInstanceGroup] = useAddHolidayInstanceGroupMutation(); + const [editHolidayInstanceGroup] = useEditHolidayInstanceGroupMutation(); + const [deleteHolidayInstanceGroup] = useDeleteHolidayInstanceGroupMutation(); - return [ - ...currentGroups, - { - id: nextId, - name, - holidayInstanceIds, - note - } - ]; - }); + const handleCreateHolidayInstanceGroup = async ( + name: string, + holidayInstanceIds: number[], + note: string + ) => { + setMutationError(''); + try { + await addHolidayInstanceGroup({ name, holidayInstanceIds, note }).unwrap(); + } catch (error) { + setMutationError(`Unable to create the holiday instance group: ${JSON.stringify(error)}`); } }; - const handleUpdateHolidayInstanceGroup = ( + const handleUpdateHolidayInstanceGroup = async ( holidayInstanceGroupId: number, name: string, holidayInstanceIds: number[], note: string ) => { - props.handleUpdateHolidayInstanceGroup?.( - holidayInstanceGroupId, - name, - holidayInstanceIds, - note - ); - - if (usingLocalHolidayInstanceGroups) { - setCreatedHolidayInstanceGroups(currentGroups => currentGroups.map(group => - group.id === holidayInstanceGroupId - ? { ...group, name, holidayInstanceIds, note } - : group - )); + setMutationError(''); + try { + await editHolidayInstanceGroup({ + id: holidayInstanceGroupId, + name, + holidayInstanceIds, + note + }).unwrap(); + } catch (error) { + setMutationError(`Unable to update the holiday instance group: ${JSON.stringify(error)}`); } }; - const handleDeleteHolidayInstanceGroup = (holidayInstanceGroupId: number) => { - props.handleDeleteHolidayInstanceGroup?.(holidayInstanceGroupId); - - if (usingLocalHolidayInstanceGroups) { - setCreatedHolidayInstanceGroups(currentGroups => - currentGroups.filter(group => group.id !== holidayInstanceGroupId) - ); + const handleDeleteHolidayInstanceGroup = async (holidayInstanceGroupId: number) => { + setMutationError(''); + try { + await deleteHolidayInstanceGroup({ id: holidayInstanceGroupId }).unwrap(); + } catch (error) { + setMutationError(`Unable to delete the holiday instance group: ${JSON.stringify(error)}`); } }; + const isLoading = holidayInstancesLoading || holidayInstanceGroupsLoading; + const loadError = holidayInstancesError || holidayInstanceGroupsError; + return (
@@ -96,6 +92,14 @@ export default function HolidayInstanceGroupComponent(props: HolidayInstanceGrou id='holiday.instance.groups' /> + {loadError && ( +
+ Unable to load holiday instance group data from the database. +
+ )} + {mutationError && ( +
{mutationError}
+ )}
- {[...holidayInstanceGroups] + {isLoading ? ( +
Loading holiday instance groups...
+ ) : [...holidayInstanceGroups] .sort((firstGroup, secondGroup) => firstGroup.id - secondGroup.id) .map(holidayInstanceGroup => ( ({}) diff --git a/src/client/app/redux/api/holidayInstanceGroupsApi.temp.ts b/src/client/app/redux/api/holidayInstanceGroupsApi.temp.ts deleted file mode 100644 index accb6085bb..0000000000 --- a/src/client/app/redux/api/holidayInstanceGroupsApi.temp.ts +++ /dev/null @@ -1,235 +0,0 @@ -/* 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 { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; - -/* - * TEMPORARY API MODULE - * - * Update these paths to match where the two Express routers are mounted. - * For example, if the server uses: - * app.use('/api/holidayInstanceGroups', holidayInstanceGroupsRouter) - * app.use('/api/holidayGroupMembers', holidayGroupMembersRouter) - * then the values below are correct with baseUrl '/api/'. - */ -const HOLIDAY_INSTANCE_GROUPS_PATH = 'holidayInstanceGroups'; -const HOLIDAY_GROUP_MEMBERS_PATH = 'holidayGroupMembers'; - -export interface HolidayInstanceGroup { - id: number; - name: string; - note: string; -} - -export interface HolidayGroupMember { - holidayInstanceGroupId: number; - holidayInstanceId: number; - holidayInstanceName?: string; - holidayId?: number; - dayPatternId?: number; - holidayName?: string; - startDate?: string; - location?: string; - dayPatternName?: string; -} - -export interface HolidayInstanceGroupWithMembers extends HolidayInstanceGroup { - holidayInstanceIds: number[]; -} - -export interface CreateHolidayInstanceGroupRequest { - name: string; - note: string; - holidayInstanceIds: number[]; -} - -export interface EditHolidayInstanceGroupRequest extends CreateHolidayInstanceGroupRequest { - id: number; -} - -export const holidayInstanceGroupsApi = createApi({ - reducerPath: 'holidayInstanceGroupsApi', - baseQuery: fetchBaseQuery({ - baseUrl: '/api/', - credentials: 'include' - }), - tagTypes: ['HolidayInstanceGroups'], - endpoints: builder => ({ - getHolidayInstanceGroups: builder.query({ - query: () => HOLIDAY_INSTANCE_GROUPS_PATH, - providesTags: ['HolidayInstanceGroups'] - }), - - getHolidayGroupMembers: builder.query({ - query: holidayInstanceGroupId => - `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${holidayInstanceGroupId}`, - providesTags: ['HolidayInstanceGroups'] - }), - - getHolidayInstanceGroupsWithMembers: builder.query({ - async queryFn(_argument, _queryApi, _extraOptions, fetchWithBQ) { - const groupsResult = await fetchWithBQ(HOLIDAY_INSTANCE_GROUPS_PATH); - if (groupsResult.error) { - return { error: groupsResult.error }; - } - - const groups = groupsResult.data as HolidayInstanceGroup[]; - const groupsWithMembers: HolidayInstanceGroupWithMembers[] = []; - for (const group of groups) { - const membersResult = await fetchWithBQ( - `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${group.id}` - ); - if (membersResult.error) { - return { error: membersResult.error }; - } - - const members = membersResult.data as HolidayGroupMember[]; - groupsWithMembers.push({ - ...group, - holidayInstanceIds: members.map(member => member.holidayInstanceId) - }); - } - - return { data: groupsWithMembers }; - }, - providesTags: ['HolidayInstanceGroups'] - }), - - addHolidayInstanceGroup: builder.mutation({ - async queryFn(request, _queryApi, _extraOptions, fetchWithBQ) { - const groupResult = await fetchWithBQ({ - url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/addHolidayInstanceGroup`, - method: 'POST', - body: { - name: request.name, - note: request.note - } - }); - if (groupResult.error) { - return { error: groupResult.error }; - } - - const createdGroup = groupResult.data as HolidayInstanceGroup; - for (const holidayInstanceId of request.holidayInstanceIds) { - const memberResult = await fetchWithBQ({ - url: `${HOLIDAY_GROUP_MEMBERS_PATH}/add`, - method: 'POST', - body: { - holidayInstanceGroupId: createdGroup.id, - holidayInstanceId - } - }); - if (memberResult.error) { - return { error: memberResult.error }; - } - } - - return { - data: { - ...createdGroup, - holidayInstanceIds: request.holidayInstanceIds - } - }; - }, - invalidatesTags: ['HolidayInstanceGroups'] - }), - - editHolidayInstanceGroup: builder.mutation({ - async queryFn(request, _queryApi, _extraOptions, fetchWithBQ) { - const membersResult = await fetchWithBQ( - `${HOLIDAY_GROUP_MEMBERS_PATH}/group/${request.id}` - ); - if (membersResult.error) { - return { error: membersResult.error }; - } - - const currentMembers = membersResult.data as HolidayGroupMember[]; - const currentIds = new Set(currentMembers.map(member => member.holidayInstanceId)); - const requestedIds = new Set(request.holidayInstanceIds); - - const groupResult = await fetchWithBQ({ - url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/edit`, - method: 'POST', - body: { - id: request.id, - name: request.name, - note: request.note - } - }); - if (groupResult.error) { - return { error: groupResult.error }; - } - - for (const holidayInstanceId of Array.from(currentIds)) { - if (!requestedIds.has(holidayInstanceId)) { - const deleteResult = await fetchWithBQ({ - url: `${HOLIDAY_GROUP_MEMBERS_PATH}/delete`, - method: 'POST', - body: { - holidayInstanceGroupId: request.id, - holidayInstanceId - } - }); - if (deleteResult.error) { - return { error: deleteResult.error }; - } - } - } - - for (const holidayInstanceId of Array.from(requestedIds)) { - if (!currentIds.has(holidayInstanceId)) { - const addResult = await fetchWithBQ({ - url: `${HOLIDAY_GROUP_MEMBERS_PATH}/add`, - method: 'POST', - body: { - holidayInstanceGroupId: request.id, - holidayInstanceId - } - }); - if (addResult.error) { - return { error: addResult.error }; - } - } - } - - return { data: undefined }; - }, - invalidatesTags: ['HolidayInstanceGroups'] - }), - - deleteHolidayInstanceGroup: builder.mutation({ - async queryFn(holidayInstanceGroupId, _queryApi, _extraOptions, fetchWithBQ) { - const membersResult = await fetchWithBQ({ - url: `${HOLIDAY_GROUP_MEMBERS_PATH}/deleteByGroup`, - method: 'POST', - body: { holidayInstanceGroupId } - }); - if (membersResult.error) { - return { error: membersResult.error }; - } - - const groupResult = await fetchWithBQ({ - url: `${HOLIDAY_INSTANCE_GROUPS_PATH}/delete`, - method: 'POST', - body: { id: holidayInstanceGroupId } - }); - if (groupResult.error) { - return { error: groupResult.error }; - } - - return { data: undefined }; - }, - invalidatesTags: ['HolidayInstanceGroups'] - }) - }) -}); - -export const { - useGetHolidayInstanceGroupsQuery, - useGetHolidayGroupMembersQuery, - useGetHolidayInstanceGroupsWithMembersQuery, - useAddHolidayInstanceGroupMutation, - useEditHolidayInstanceGroupMutation, - useDeleteHolidayInstanceGroupMutation -} = holidayInstanceGroupsApi; diff --git a/src/client/app/redux/api/holidayInstanceGroupsApi.ts b/src/client/app/redux/api/holidayInstanceGroupsApi.ts new file mode 100644 index 0000000000..151226bb87 --- /dev/null +++ b/src/client/app/redux/api/holidayInstanceGroupsApi.ts @@ -0,0 +1,234 @@ +/* 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 => ({ + 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' }] + }), + + 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' }] + }), + + 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: undefined }; + }, + invalidatesTags: (_result, _error, arg) => [ + { type: 'HolidayInstanceGroups', id: arg.id }, + { type: 'HolidayInstanceGroups', id: 'LIST' } + ] + }), + + 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: undefined }; + }, + 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/types/redux/holidays.ts b/src/client/app/types/redux/holidays.ts index 677e937ca6..9b2076569e 100644 --- a/src/client/app/types/redux/holidays.ts +++ b/src/client/app/types/redux/holidays.ts @@ -55,3 +55,31 @@ export interface HolidayInstanceDetails extends HolidayInstance { 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; +} From f3e2d88313177d6055a880a4e4b023eb08f7129d Mon Sep 17 00:00:00 2001 From: JSmevog Date: Thu, 23 Jul 2026 19:47:13 -0700 Subject: [PATCH 155/170] Added route for Holiday --- src/client/app/components/RouteComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index a3a5f7b0eb..c83c6ea87d 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -28,7 +28,7 @@ import NotFound from './router/NotFoundOutlet'; import RoleOutlet from './router/RoleOutlet'; import UnitsDetailComponent from './unit/UnitsDetailComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; -import HolidayInstanceGroupComponent from './holiday/HolidayInstanceGroupComponent'; +import HolidayInstanceGroupComponent from './holidayInstanceGroup/HolidayInstanceGroupComponent'; import WeeksDetailComponent from './weeks/WeeksDetailComponent'; import HolidayInstancePage from './holiday-instances/HolidayInstancePage'; import HolidayPage from './holiday/HolidayPage'; From e5297e0375d935fd01a2457a213262b9e55f3fae Mon Sep 17 00:00:00 2001 From: Rose Date: Sat, 25 Jul 2026 12:20:10 -0700 Subject: [PATCH 156/170] Add holiday rates to admin menu --- .../app/components/HeaderButtonsComponent.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index 3f623bd534..aae6b70a73 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -58,6 +58,8 @@ export default function HeaderButtonsComponent() { shouldAdminButtonDisabled: true, shouldUsersButtonDisabled: true, shouldGroupsButtonDisabled: true, + shouldHolidaysButtonDisabled: true, + shouldHolidayInstancesButtonDisabled: true, shouldHolidayInstanceGroupsButtonDisabled: true, shouldMetersButtonDisabled: true, shouldMapsButtonDisabled: true, @@ -66,7 +68,6 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: true, shouldConversionsButtonDisabled: true, shouldDaysButtonDisabled: true, - shouldHolidaysButtonDisabled: true, shouldWeeksButtonDisabled: true, shouldLogMsgButtonDisabled: true, shouldVisualUnitMapButtonDisabled: true, @@ -100,6 +101,8 @@ 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', @@ -108,7 +111,6 @@ export default function HeaderButtonsComponent() { shouldUnitsButtonDisabled: pathname === '/units', shouldConversionsButtonDisabled: pathname === '/conversions', shouldDaysButtonDisabled: pathname === '/days', - shouldHolidaysButtonDisabled: pathname === '/holidays', shouldWeeksButtonDisabled: pathname === '/weeks', shouldLogMsgButtonDisabled: pathname === '/logmsg', shouldVisualUnitMapButtonDisabled: pathname === '/visual-unit' @@ -221,17 +223,24 @@ export default function HeaderButtonsComponent() { - + to="/holidays"> + - + to="/holiday-instances"> + + + + Date: Sat, 25 Jul 2026 14:03:58 -0700 Subject: [PATCH 157/170] Polish holiday pages and translations --- .../app/components/holiday/HolidayPage.tsx | 2 +- ...eateHolidayInstanceGroupModalComponent.tsx | 4 ++- ...EditHolidayInstanceGroupModalComponent.tsx | 10 ++++--- .../HolidayInstanceGroupComponent.tsx | 20 +++++++++---- .../HolidayInstanceGroupViewComponent.jsx | 14 +++++---- src/client/app/translations/data.ts | 30 +++++++++++++++++-- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/client/app/components/holiday/HolidayPage.tsx b/src/client/app/components/holiday/HolidayPage.tsx index d77b801e11..f19ee3d894 100644 --- a/src/client/app/components/holiday/HolidayPage.tsx +++ b/src/client/app/components/holiday/HolidayPage.tsx @@ -238,7 +238,7 @@ export default function HolidayPage() { disabled={stateCode.length === 0 || regions.length === 0} > {regions.map(region => (
{isLoading ? ( -
Loading holiday instance groups...
+
+ +
) : [...holidayInstanceGroups] .sort((firstGroup, secondGroup) => firstGroup.id - secondGroup.id) .map(holidayInstanceGroup => ( diff --git a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx index 931ef86a5a..8154a7e223 100644 --- a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx +++ b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx @@ -11,11 +11,11 @@ import EditHolidayInstanceGroupModalComponent from './EditHolidayInstanceGroupMo /** * 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}>} 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. + * @param props Component properties. + * @param props.holidayInstanceGroup Group data. + * @param props.holidayInstances Available holiday instances. + * @param props.onEditHolidayInstanceGroup Edit handler. + * @param [props.onDeleteHolidayInstanceGroup] Optional delete handler. * @returns Holiday instance group card element. */ export default function HolidayInstanceGroupViewComponent(props) { @@ -57,7 +57,9 @@ export default function HolidayInstanceGroupViewComponent(props) { `...(${selectedHolidayInstances.length})`} ) : ( - None + + + )}
diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 139c975119..58a125c8c9 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -395,10 +395,17 @@ const LocaleTranslationData = { "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 with error: ", "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 with error: ", "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 with error: ", + "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.", @@ -420,7 +427,7 @@ const LocaleTranslationData = { "holiday.rates": "Holiday Rates", "holiday.region.unavailable": "Unavailable", "holiday.region": "Region: ", - "holiday.region.select": "Select a region", + "holiday.region.none": "None", "holiday.saved": "Saved Holidays", "holiday.state": "State/Province:", "holiday.state.select": "Select a state or province", @@ -610,6 +617,7 @@ const LocaleTranslationData = { "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", @@ -1134,10 +1142,17 @@ const LocaleTranslationData = { "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 with error: \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 with error: \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 with error: \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}", @@ -1157,7 +1172,7 @@ const LocaleTranslationData = { "holiday.rate.save": "Save Holiday Rate\u{26A1}", "holiday.rates": "Holiday Rates\u{26A1}", "holiday.region": "Region:\u{26A1}", - "holiday.region.select": "Select a 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}", @@ -1348,6 +1363,7 @@ const LocaleTranslationData = { "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}", @@ -1871,10 +1887,17 @@ const LocaleTranslationData = { "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 with error: \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 with error: \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 with error: \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}", @@ -1895,7 +1918,7 @@ const LocaleTranslationData = { "holiday.rate.save": "Save Holiday Rate\u{26A1}", "holiday.rates": "Holiday Rates\u{26A1}", "holiday.region": "Region:\u{26A1}", - "holiday.region.select": "Select a 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}", @@ -2086,6 +2109,7 @@ const LocaleTranslationData = { "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", From ef99950f9cd414056289953f4cc41d81c5d36036 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Sat, 25 Jul 2026 15:25:41 -0700 Subject: [PATCH 158/170] deleted unnecessary test file. Made changes so that location select is working. Filters Holiday rates based on location. --- ...eateHolidayInstanceGroupModalComponent.tsx | 115 ++++++++--- ...EditHolidayInstanceGroupModalComponent.tsx | 117 ++++++++--- .../HolidayInstanceGroupComponent.tsx | 10 +- .../HolidayInstanceGroupViewComponent.jsx | 22 +- .../holidayInstanceTestData.ts | 191 ------------------ 5 files changed, 185 insertions(+), 270 deletions(-) delete mode 100644 src/client/app/components/holidayInstanceGroup/holidayInstanceTestData.ts diff --git a/src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx index 57eaac2155..8db5c61306 100644 --- a/src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx @@ -21,14 +21,17 @@ import { } from 'reactstrap'; import MultiSelectComponent from '../MultiSelectComponent'; import { SelectOption } from '../../types/items'; -import { HolidayInstance } from '../../types/redux/holidays'; -import { useTranslate } from '../../redux/componentHooks'; +import { HolidayInstanceDetails } from '../../types/redux/holidays'; interface CreateHolidayInstanceGroupModalComponentProps { - holidayInstances: HolidayInstance[]; + 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. @@ -37,16 +40,36 @@ interface CreateHolidayInstanceGroupModalComponentProps { export default function CreateHolidayInstanceGroupModalComponent( props: CreateHolidayInstanceGroupModalComponentProps ) { - const translate = useTranslate(); const [showModal, setShowModal] = useState(false); const [name, setName] = useState(''); + const [selectedLocations, setSelectedLocations] = useState([]); const [holidayInstanceIds, setHolidayInstanceIds] = useState([]); const [note, setNote] = useState(''); + 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) + ); + const filteredHolidayInstances = props.holidayInstances.filter(holidayInstance => + selectedLocations.includes(holidayInstance.location.trim()) + ); const sortedHolidayInstances = React.useMemo( - () => [...props.holidayInstances].sort((first, second) => + () => [...filteredHolidayInstances].sort((first, second) => first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), - [props.holidayInstances] + [filteredHolidayInstances] ); const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ label: holidayInstance.name, @@ -58,11 +81,15 @@ export default function CreateHolidayInstanceGroupModalComponent( const resetState = () => { setName(''); + setSelectedLocations([]); setHolidayInstanceIds([]); setNote(''); }; - const handleShow = () => setShowModal(true); + const handleShow = () => { + setSelectedLocations(locations.length === 1 ? locations : []); + setShowModal(true); + }; const handleClose = () => { setShowModal(false); @@ -117,6 +144,52 @@ export default function CreateHolidayInstanceGroupModalComponent( + + + + {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 */} @@ -128,7 +201,11 @@ export default function CreateHolidayInstanceGroupModalComponent( 0 + ? 'Select holiday instances' + : 'Select a location first' + } onValuesChange={(newSelectedHolidayOptions: SelectOption[]) => { const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( holidayInstance => holidayInstance.value @@ -143,28 +220,6 @@ export default function CreateHolidayInstanceGroupModalComponent( )} - - - - - - - - diff --git a/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx index 161985194a..ef7605026b 100644 --- a/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx @@ -22,13 +22,12 @@ import { import ConfirmActionModalComponent from '../ConfirmActionModalComponent'; import MultiSelectComponent from '../MultiSelectComponent'; import { SelectOption } from '../../types/items'; -import { HolidayInstance, HolidayInstanceGroup } from '../../types/redux/holidays'; -import { useTranslate } from '../../redux/componentHooks'; +import { HolidayInstanceDetails, HolidayInstanceGroup } from '../../types/redux/holidays'; interface EditHolidayInstanceGroupModalComponentProps { show: boolean; holidayInstanceGroup: HolidayInstanceGroup; - holidayInstances: HolidayInstance[]; + holidayInstances: HolidayInstanceDetails[]; handleShow: () => void; handleClose: () => void; onEditHolidayInstanceGroup: ( @@ -40,6 +39,10 @@ interface EditHolidayInstanceGroupModalComponentProps { 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. @@ -48,18 +51,38 @@ interface EditHolidayInstanceGroupModalComponentProps { 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); + 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) + ); + const filteredHolidayInstances = props.holidayInstances.filter(holidayInstance => + selectedLocations.includes(holidayInstance.location.trim()) + ); const sortedHolidayInstances = React.useMemo( - () => [...props.holidayInstances].sort((first, second) => + () => [...filteredHolidayInstances].sort((first, second) => first.name.localeCompare(second.name, undefined, { sensitivity: 'base' })), - [props.holidayInstances] + [filteredHolidayInstances] ); const holidayInstanceOptions: SelectOption[] = sortedHolidayInstances.map(holidayInstance => ({ label: holidayInstance.name, @@ -72,10 +95,16 @@ export default function EditHolidayInstanceGroupModalComponent( const validHolidayInstanceGroup = validName && holidayInstanceIds.length > 0; 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); - }, [props.holidayInstanceGroup]); + }, [locations, props.holidayInstanceGroup, props.holidayInstances]); useEffect(() => { if (props.show) { @@ -155,6 +184,52 @@ export default function EditHolidayInstanceGroupModalComponent( + + + + {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 */} @@ -166,7 +241,11 @@ export default function EditHolidayInstanceGroupModalComponent( 0 + ? 'Select holiday instances' + : 'Select a location first' + } onValuesChange={(newSelectedHolidayOptions: SelectOption[]) => { const updatedHolidayInstanceIds = newSelectedHolidayOptions.map( holidayInstance => holidayInstance.value @@ -181,28 +260,6 @@ export default function EditHolidayInstanceGroupModalComponent( )} - - - - - - - - diff --git a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx index d37db00a80..9fc1e6c412 100644 --- a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx @@ -6,8 +6,7 @@ import * as React from 'react'; import { useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { - stableEmptyHolidayInstances, - useGetHolidayInstancesQuery + useGetHolidayInstancesWithDetailsQuery } from '../../redux/api/holidayInstancesApi'; import { stableEmptyHolidayInstanceGroups, @@ -20,6 +19,9 @@ import { useTranslate } from '../../redux/componentHooks'; import { titleStyle } from '../../styles/modalStyle'; import CreateHolidayInstanceGroupModalComponent from './CreateHolidayInstanceGroupModalComponent'; import HolidayInstanceGroupViewComponent from './HolidayInstanceGroupViewComponent'; +import { HolidayInstanceDetails } from '../../types/redux/holidays'; + +const stableEmptyHolidayInstanceDetails: HolidayInstanceDetails[] = []; /** * Defines the holiday instance group card page. @@ -29,10 +31,10 @@ export default function HolidayInstanceGroupComponent() { const translate = useTranslate(); const [mutationError, setMutationError] = useState(''); const { - data: holidayInstances = stableEmptyHolidayInstances, + data: holidayInstances = stableEmptyHolidayInstanceDetails, isLoading: holidayInstancesLoading, error: holidayInstancesError - } = useGetHolidayInstancesQuery(); + } = useGetHolidayInstancesWithDetailsQuery(); const { data: holidayInstanceGroups = stableEmptyHolidayInstanceGroups, isLoading: holidayInstanceGroupsLoading, diff --git a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx index 8154a7e223..8bd5f4b486 100644 --- a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx +++ b/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupViewComponent.jsx @@ -11,11 +11,11 @@ import EditHolidayInstanceGroupModalComponent from './EditHolidayInstanceGroupMo /** * Defines a holiday instance group information card. - * @param props Component properties. - * @param props.holidayInstanceGroup Group data. - * @param props.holidayInstances Available holiday instances. - * @param props.onEditHolidayInstanceGroup Edit handler. - * @param [props.onDeleteHolidayInstanceGroup] Optional delete handler. + * @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) { @@ -31,7 +31,8 @@ export default function HolidayInstanceGroupViewComponent(props) { const selectedHolidayInstances = props.holidayInstanceGroup.holidayInstanceIds .map(holidayInstanceId => props.holidayInstances.find( - holidayInstance => holidayInstance.id === holidayInstanceId + holidayInstance => + Number(holidayInstance.id) === Number(holidayInstanceId) )) .filter(holidayInstance => holidayInstance !== undefined); @@ -63,15 +64,6 @@ export default function HolidayInstanceGroupViewComponent(props) { )}
-
- - - {' '} - -
-
{' '} {displayedNote} diff --git a/src/client/app/components/holidayInstanceGroup/holidayInstanceTestData.ts b/src/client/app/components/holidayInstanceGroup/holidayInstanceTestData.ts deleted file mode 100644 index 6415e7fe47..0000000000 --- a/src/client/app/components/holidayInstanceGroup/holidayInstanceTestData.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* 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 const testHolidayInstances: HolidayInstance[] = [ - { - id: 1, - name: 'New Year\'s Day', - note: 'Applies the standard holiday day pattern for New Year\'s Day.', - holidayId: 1, - dayPatternId: 101 - }, - { - id: 2, - name: 'Martin Luther King Jr. Day', - note: 'Observed holiday with reduced commercial demand.', - holidayId: 2, - dayPatternId: 102 - }, - { - id: 3, - name: 'Presidents\' Day', - note: 'Listed for review, but currently uses the normal weekday pattern.', - holidayId: 3, - dayPatternId: 103 - }, - { - id: 4, - name: 'Memorial Day', - note: 'Applies all-day holiday pricing.', - holidayId: 4, - dayPatternId: 101 - }, - { - id: 5, - name: 'Juneteenth', - note: 'Federal holiday with expected lower commercial load.', - holidayId: 5, - dayPatternId: 102 - }, - { - id: 6, - name: 'Independence Day', - note: 'Holiday rate applies, but cooling demand may remain elevated.', - holidayId: 6, - dayPatternId: 104 - }, - { - id: 7, - name: 'Labor Day', - note: 'Applies the utility holiday rate pattern.', - holidayId: 7, - dayPatternId: 101 - }, - { - id: 8, - name: 'Columbus Day', - note: 'No special rate override for this test instance.', - holidayId: 8, - dayPatternId: 103 - }, - { - id: 9, - name: 'Veterans Day', - note: 'Observed holiday with moderate demand reduction.', - holidayId: 9, - dayPatternId: 102 - }, - { - id: 10, - name: 'Thanksgiving Day', - note: 'Major holiday with low commercial demand.', - holidayId: 10, - dayPatternId: 101 - }, - { - id: 11, - name: 'Day After Thanksgiving', - note: 'Optional bridge holiday used by some locations.', - holidayId: 11, - dayPatternId: 105 - }, - { - id: 12, - name: 'Christmas Eve', - note: 'Partial holiday behavior with lower evening demand.', - holidayId: 12, - dayPatternId: 106 - }, - { - id: 13, - name: 'Christmas Day', - note: 'Applies all-day holiday pricing.', - holidayId: 13, - dayPatternId: 101 - }, - { - id: 14, - name: 'New Year\'s Eve', - note: 'Demand may increase late in the day; no full holiday override.', - holidayId: 14, - dayPatternId: 107 - }, - { - id: 15, - name: 'Cesar Chavez Day', - note: 'California regional holiday for test grouping.', - holidayId: 15, - dayPatternId: 102 - }, - { - id: 16, - name: 'Good Friday', - note: 'Optional closure pattern for supported regions.', - holidayId: 16, - dayPatternId: 105 - }, - { - id: 17, - name: 'Easter Monday', - note: 'International holiday test instance.', - holidayId: 17, - dayPatternId: 105 - }, - { - id: 18, - name: 'Diwali', - note: 'Cultural holiday instance for non-US holiday testing.', - holidayId: 18, - dayPatternId: 108 - }, - { - id: 19, - name: 'Lunar New Year', - note: 'International holiday with location-specific behavior.', - holidayId: 19, - dayPatternId: 108 - }, - { - id: 20, - name: 'Eid al-Fitr', - note: 'Regional holiday instance with reduced commercial load.', - holidayId: 20, - dayPatternId: 108 - }, - { - id: 21, - name: 'Rosh Hashanah', - note: 'Cultural holiday test instance.', - holidayId: 21, - dayPatternId: 108 - }, - { - id: 22, - name: 'Yom Kippur', - note: 'Cultural holiday with low business activity in selected regions.', - holidayId: 22, - dayPatternId: 108 - }, - { - id: 23, - name: 'Election Day', - note: 'Civic holiday test case with normal weekday fallback.', - holidayId: 23, - dayPatternId: 103 - }, - { - id: 24, - name: 'Utility Flex Holiday', - note: 'Synthetic utility holiday for testing custom groups.', - holidayId: 24, - dayPatternId: 109 - }, - { - id: 25, - name: 'Emergency Closure Day', - note: 'Synthetic closure day used to test manually created groups.', - holidayId: 25, - dayPatternId: 110 - } -]; - -export default testHolidayInstances; From 09e7dc361028609ce33cd1fe4ebb88a45d517861 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Sat, 25 Jul 2026 16:09:47 -0700 Subject: [PATCH 159/170] Reverted a deletion that stopped it from working --- .../EditHolidayInstanceGroupModalComponent.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx index ef7605026b..620dfe39bf 100644 --- a/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx @@ -23,6 +23,7 @@ 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; @@ -51,6 +52,7 @@ interface LocationOption extends SelectOption { export default function EditHolidayInstanceGroupModalComponent( props: EditHolidayInstanceGroupModalComponentProps ) { + const translate = useTranslate(); const [name, setName] = useState(props.holidayInstanceGroup.name ?? ''); const [selectedLocations, setSelectedLocations] = useState([]); const [holidayInstanceIds, setHolidayInstanceIds] = useState( From ef78d8f26846ecb9337c36ccbdd9c967feec04a2 Mon Sep 17 00:00:00 2001 From: Tariq Kakar Date: Tue, 28 Jul 2026 14:17:11 -0700 Subject: [PATCH 160/170] Address code review on holidayFrontEnd base - Alphabetize the three holiday entries in the Pages menu - Use state field names directly; remove the renaming adapter layer - Keep location labels for base holidays (Name (Location)) - Move state types to types/redux; drop the Omit patch type - Remove unused getHolidayInstancesByHolidayId endpoint - Rename EMPTY_DRAFT -> defaultValues, isDirty -> hasUnsavedChanges - Remove loading gate; add tooltip help icon + translation key - Comment cleanup: no meeting/date/person references; TODOs marked --- .../app/components/HeaderButtonsComponent.tsx | 12 +- .../holiday-instances/HolidayInstancePage.tsx | 486 ++++++------------ .../app/redux/api/holidayInstancesApi.ts | 17 +- src/client/app/translations/data.ts | 6 +- src/client/app/types/redux/holidays.ts | 10 +- 5 files changed, 162 insertions(+), 369 deletions(-) diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index aae6b70a73..d10e08fc71 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -223,10 +223,10 @@ export default function HeaderButtonsComponent() { - + to="/holiday-instance-groups"> + - + to="/holidays"> + name, baseHolidayId <-> holidayId, - * patternId <-> dayPatternId). The component body consumes only the hook - * return values, so it did not change in the swap. - * - * REGION (meeting 4 / 7-2): Steve wants a region selector that auto-populates - * the base-holiday dropdown from the selected region's holidays. That is - * blocked on Rose's region API + the team's single-vs-multi-region decision, - * so it is stubbed below (see `REGION STUB`). For now the base-holiday - * dropdown is fed by the full mock holiday list. - * - * UNIQUENESS (meeting 3, clarified 7/7): multiple instances of the SAME base - * holiday (at different rates) are allowed here on purpose — no uniqueness - * check belongs on this page. The "same holiday twice" guard lives on Jason's - * group page. - * - * TERMINOLOGY (meeting 4): user-facing text says "Holiday Rate" (Steve's - * suggestion — "instance" is too technical for admins). Internal names keep - * "instance" to match the holiday_instance table and route names. Wording - * mirrors the Units page: "Holiday Rates" / "Create a Holiday Rate" / - * "Details/Edit Holiday Rate". - * - * I18N (done 7/18): all user-facing strings go through translate('...') with - * keys in translations/data.ts. Generic keys (cancel, note, discard.changes, - * unsaved.warning) are reused per Steve's guidance; new keys live under - * holiday.* / day.pattern.* with lightning-bolt placeholders in fr/es. - * ------------------------------------------------------------------------- - */ - -interface BaseHoliday { - id: number; - name: string; - location: string; -} +// 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. -interface DayPatternOption { - id: number; - name: string; -} - -/* - * Create vs Edit types (meeting 4 / 7-2): the base payload carries NO id. - * Create sends exactly this. Edit extends it with the id. This keeps `create` - * from ever sending an id, matching OED's tightened route parameter checks. - */ -interface HolidayInstanceData { - instanceName: string; - baseHolidayId: number; - patternId: number; - note: string; -} - -interface HolidayInstance extends HolidayInstanceData { - id: number; -} - -/* - * Edit payload. The base holiday is immutable after create (meeting-4 - * decision: changing region/base holiday invalidates the instance — delete - * and recreate instead), so the PATCH payload omits `baseHolidayId` entirely - * rather than resending it. Matches OED's tightened route parameter checking: - * don't send fields the route won't accept. - */ -type HolidayInstancePatch = Omit & { id: number }; - -// Base holidays from the `holidays` table (populated via Rose's page). -const useHolidays = () => { - const { data = stableEmptyHolidays, isFetching } = useGetHolidaysQuery(); - const mapped = useMemo( - () => data.map(holiday => ({ - id: holiday.id, - name: holiday.name, - location: holiday.location - })), - [data] - ); - return { data: mapped, isFetching }; -}; - -// Day patterns: OED's existing days slice (`day_patterns` table). Day -// ({ id, name, note }) structurally satisfies DayPatternOption. -const useDayPatterns = (): { data: DayPatternOption[]; isFetching: boolean } => { - const { data = stableEmptyDays, isFetching } = useGetDaysQuery(); - return { data, isFetching }; -}; - -/* - * Instances + mutations. Each mutation invalidates the HolidayInstances cache - * tag, so the list refetches from the server — global state is never written - * from the local copy (Steve's state model, meeting 4). - */ -const useHolidayInstances = () => { - const translate = useTranslate(); - const { data = stableEmptyHolidayInstances } = useGetHolidayInstancesQuery(); - const [addMutation] = useAddHolidayInstanceMutation(); - const [editMutation] = useEditHolidayInstanceMutation(); - const [deleteMutation] = useDeleteHolidayInstanceMutation(); - - const instances = useMemo( - () => data.map(i => ({ - id: i.id, - instanceName: i.name, - baseHolidayId: i.holidayId, - patternId: i.dayPatternId, - note: i.note ?? '' - })), - [data] - ); - - const addInstance = (draft: HolidayInstanceData) => { - addMutation({ - name: draft.instanceName, - holidayId: draft.baseHolidayId, - dayPatternId: draft.patternId, - note: draft.note - }).unwrap() - .then(() => showSuccessNotification(translate('holiday.rate.create.success'))) - .catch(error => showErrorNotification(translate('holiday.rate.create.failure') + error)); - }; - - const editInstance = (patch: HolidayInstancePatch) => { - // The edit route REQUIRES holidayId even though the UI locks the base - // holiday after create — resend the stored (unchanged) value. - const existing = data.find(i => i.id === patch.id); - if (existing === undefined) { - showErrorNotification(translate('holiday.rate.edit.failure')); - return; - } - editMutation({ - id: patch.id, - name: patch.instanceName, - holidayId: existing.holidayId, - dayPatternId: patch.patternId, - note: patch.note - }).unwrap() - .then(() => showSuccessNotification(translate('holiday.rate.edit.success'))) - .catch(error => showErrorNotification(translate('holiday.rate.edit.failure') + error)); - }; - - // TODO(meeting 5): interlock — before deleting, sweep Redux state to check - // the instance is not used in a holiday group; otherwise the foreign key - // makes the server return a database error. - const deleteInstance = (id: number) => { - deleteMutation({ id }).unwrap() - .then(() => showSuccessNotification(translate('holiday.rate.delete.success'))) - .catch(error => showErrorNotification(translate('holiday.rate.delete.failure') + error)); - }; - - return { data: instances, addInstance, editInstance, deleteInstance }; -}; - -/* End data seam */ - -// Sentinel values for "nothing selected yet" dropdown state, mirroring the +// 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 (meeting 6): names clip at 15 chars (Steve: put the critical - * information in the first characters); notes keep the conversion note/log - * convention of 30. - */ +// 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; 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 EMPTY_DRAFT: HolidayInstanceData = { - instanceName: '', - baseHolidayId: NO_HOLIDAY, - patternId: NO_PATTERN, +const defaultValues: CreateHolidayInstancePayload = { + name: '', + holidayId: NO_HOLIDAY, + dayPatternId: NO_PATTERN, note: '' }; type ModalMode = 'create' | 'edit'; -/* - * Small yes/no confirmation modal shaped like OED's ConfirmActionModalComponent. - * TODO(repo): once this file lives in the OED repo, replace this local - * stand-in with the shared ConfirmActionModalComponent. - */ interface ConfirmModalProps { isOpen: boolean; message: string; @@ -222,6 +66,8 @@ interface ConfirmModalProps { } /** + * 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 */ @@ -243,125 +89,107 @@ function ConfirmModal(props: ConfirmModalProps) { } /** - * Holiday Instance page. Follows OED's standard entity-page pattern (cf. Units): - * 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 Instance page element + * 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(); - /* Data (real backend via the adapter hooks in the seam above) */ - const { data: holidaysData, isFetching: holidaysFetching } = useHolidays(); - const { data: dayPatterns, isFetching: patternsFetching } = useDayPatterns(); - const { data: instances, addInstance, editInstance, deleteInstance } = useHolidayInstances(); - - /* - * REGION STUB (meeting 4): when the region selector lands, `availableHolidays` - * becomes "holidays for the selected region(s)" instead of the full list, and - * a for region gets added to the page header. The rest - * of this component does not need to change — it only reads `availableHolidays`. - * - * Sorted by name (meeting 4: ~50 holidays/region; date sorting is awkward - * because the date varies by year, so name is the safe default). - */ - const availableHolidays = useMemo( - () => [...holidaysData].sort((a, b) => a.name.localeCompare(b.name)), - [holidaysData] - ); - const holidayLabel = (holiday: BaseHoliday) => { - return `${holiday.name} (${holiday.location})`; - }; + /* 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 ---- */ + /* 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(EMPTY_DRAFT); - // Snapshot of the draft at open, used for the dirty check. - const [initialDraft, setInitialDraft] = useState(EMPTY_DRAFT); + 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); - // Meeting 5 to-do: warn (non-blocking) when the base holiday changes after - // a name was already entered, since the name may no longer match. + // 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); - /* ---- Lookups (names are derived; IDs stay internal) ---- */ - // TODO(region): once holidays are region-filtered, an instance whose base - // holiday falls outside the fetched set would have no name here. The - // instances query should return the joined holiday name (or names should be - // fetched by id) — until then, fall back to a visible placeholder rather - // than rendering blank. - const holidayName = (id: number) => { - const holiday = availableHolidays.find(item => item.id === id); - - if (holiday === undefined) { - return translate('holiday.base.unknown'); - } + const tooltipStyle = { + ...tooltipBaseStyle, + tooltipHolidayRateView: 'help.admin.holidayrateview' + }; - return holidayLabel(holiday); + /* 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 (meeting 4: with up to ~50 holidays the - * dropdown should be searchable by name). react-select is already an OED - * dependency (meter/group pickers), and it handles the type-to-filter - * behavior itself — one combobox instead of a filter box + select. - * TODO(repo): if OED's shared SingleSelectComponent fits, swap to it. - */ + // Searchable dropdown options for the base holiday. + // TODO: swap to the shared SingleSelectComponent if it fits. const holidayOptions = useMemo( - () => availableHolidays.map(holiday => ({ - value: holiday.id, - label: holidayLabel(holiday) - })), - [availableHolidays] + () => sortedHolidays.map(h => ({ value: h.id, label: holidayLabel(h) })), + [sortedHolidays] ); const selectedHolidayOption = - holidayOptions.find(o => o.value === draft.baseHolidayId) ?? null; + holidayOptions.find(o => o.value === draft.holidayId) ?? null; - /* ---- Validation (invalid/disabled pattern from the conversion modal) ---- */ - const isNameInvalid = draft.instanceName.trim().length === 0; - const isHolidayInvalid = draft.baseHolidayId === NO_HOLIDAY; - const isPatternInvalid = draft.patternId === NO_PATTERN; + /* 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; - // In create mode initialDraft === EMPTY_DRAFT, so isDirty doubles as the + // 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 isDirty = - draft.instanceName !== initialDraft.instanceName || - draft.baseHolidayId !== initialDraft.baseHolidayId || - draft.patternId !== initialDraft.patternId || + 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 - // (mirrors OED: the Save button only lights up once something really changes). - const canSave = isValid && (modalMode === 'create' || isDirty); + // 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 ---- */ + /* Handlers */ const openCreate = () => { setModalMode('create'); setEditingId(null); - setDraft(EMPTY_DRAFT); - setInitialDraft(EMPTY_DRAFT); + setDraft(defaultValues); + setInitialDraft(defaultValues); setShowModal(true); }; const openEdit = (instance: HolidayInstance) => { - const asDraft: HolidayInstanceData = { - instanceName: instance.instanceName, - baseHolidayId: instance.baseHolidayId, - patternId: instance.patternId, - note: instance.note + const asDraft: CreateHolidayInstancePayload = { + name: instance.name, + holidayId: instance.holidayId, + dayPatternId: instance.dayPatternId, + note: instance.note ?? '' }; setModalMode('edit'); setEditingId(instance.id); setDraft(asDraft); - setInitialDraft(asDraft); // dirty check compares against this + setInitialDraft(asDraft); setShowModal(true); }; @@ -373,19 +201,16 @@ export default function HolidayInstancePage() { setShowCloseConfirm(false); setShowDeleteConfirm(false); setEditingId(null); - setDraft(EMPTY_DRAFT); - setInitialDraft(EMPTY_DRAFT); + setDraft(defaultValues); + setInitialDraft(defaultValues); }; - /* - * Close attempt (backdrop click, X, or Discard). OED pattern (meeting 4): - * clicking off a modal with NO changes is safe — close silently. With - * changes, pop the "you're about to lose your work" warning. Discard goes - * through the same gate on purpose: mis-clicking Discard instead of Save is - * exactly the accidental loss Steve described. - */ + // 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 (isDirty) { + if (hasUnsavedChanges) { setShowCloseConfirm(true); } else { doClose(); @@ -397,73 +222,69 @@ export default function HolidayInstancePage() { return; } if (modalMode === 'create') { - addInstance(draft); + addHolidayInstance(draft).unwrap() + .then(() => showSuccessNotification(translate('holiday.rate.create.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.create.failure') + error)); } else if (editingId !== null) { - // baseHolidayId is immutable in edit mode (locked field below), so the - // patch deliberately omits it — see HolidayInstancePatch. - editInstance({ - id: editingId, - instanceName: draft.instanceName, - patternId: draft.patternId, - note: draft.note - }); + // 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; } - deleteInstance(editingId); + deleteHolidayInstance({ id: editingId }).unwrap() + .then(() => showSuccessNotification(translate('holiday.rate.delete.success'))) + .catch(error => showErrorNotification(translate('holiday.rate.delete.failure') + error)); doClose(); }; - if (holidaysFetching || patternsFetching) { - return ( -
-
-

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

-
-
- ); - } - return (
+ - {/* ---------- Page title (mirrors "Units") ---------- - TODO(repo): Units renders a TooltipMarkerComponent help icon next - to the title — add it once this lives in the OED repo. */} -

{translate('holiday.rates')}

+

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

- {/* ---------- Create button (mirrors "Create a Unit") ---------- */}
- {/* ---------- Instance cards ---------- */} + {/* Instance cards */}
{instances.map(instance => ( -
+
- {clip(instance.instanceName, MAX_NAME_LENGTH)} + {clip(instance.name, MAX_NAME_LENGTH)}
- {/* Bold "Label:" + value lines, like the Units cards. */}
- {translate('holiday.base')} {holidayName(instance.baseHolidayId)} + {translate('holiday.base')} {holidayName(instance.holidayId)}
- {translate('day.pattern')} {patternName(instance.patternId)} + {translate('day.pattern')} {patternName(instance.dayPatternId)}
- {instance.note.trim().length > 0 && ( + {(instance.note ?? '').trim().length > 0 && (
- {translate('note')} {clip(instance.note, MAX_NOTE_LENGTH)} + {translate('note')} {clip(instance.note ?? '', MAX_NOTE_LENGTH)}
)} - {/* Mirrors the Units card's "Details/Edit Unit" button. */}
- {/* ---------- Create / Edit modal ---------- */} + {/* Create / Edit modal */} - {/* Headers mirror Units: "Create a Holiday Rate" / "Details/Edit Holiday Rate". */} {modalMode === 'create' ? translate('holiday.rate.create') : translate('holiday.rate.edit')} @@ -487,13 +307,9 @@ export default function HolidayInstancePage() { - {/* Base holiday. - CREATE: a required searchable combobox (react-select) — click - it and type to filter, e.g. "Thanks" -> Thanksgiving. Name- - sorted, up to ~50 per region (meeting 4). - EDIT: locked. Meeting-4 decision — changing the region/base - holiday effectively invalidates the instance, so it can't be - edited in place; delete + create a new one instead. */} + {/* Base holiday: searchable combobox on create; locked on + edit - changing the base holiday invalidates the record, + so delete and recreate instead. */} {modalMode === 'create' ? ( @@ -507,19 +323,17 @@ export default function HolidayInstancePage() { const newId = option ? option.value : NO_HOLIDAY; // Warn if a real selection changes while a name is set. setShowBaseChangeWarning( - draft.baseHolidayId !== NO_HOLIDAY && - newId !== draft.baseHolidayId && - draft.instanceName.trim().length > 0 + draft.holidayId !== NO_HOLIDAY && + newId !== draft.holidayId && + draft.name.trim().length > 0 ); - setDraft(d => ({ ...d, baseHolidayId: newId })); + setDraft(d => ({ ...d, holidayId: newId })); }} placeholder={translate('holiday.base.select')} isClearable - // Red-outline the control while invalid, matching the - // reactstrap `invalid` look on the other fields. styles={isHolidayInvalid ? invalidSelectStyles : undefined} /> - {/* react-select isn't a reactstrap input, so FormFeedback + {/* react-select is not a reactstrap input, so FormFeedback needs d-block to render next to it. */} {isHolidayInvalid && ( @@ -537,23 +351,23 @@ export default function HolidayInstancePage() { id='baseHoliday' name='baseHoliday' type='text' - value={holidayName(draft.baseHolidayId)} + value={holidayName(draft.holidayId)} disabled /> )} - {/* Instance name — required */} + {/* Instance name - required */} - + - setDraft(d => ({ ...d, instanceName: e.target.value })) + setDraft(d => ({ ...d, name: e.target.value })) } invalid={isNameInvalid} /> @@ -562,23 +376,23 @@ export default function HolidayInstancePage() { - {/* Day pattern — required. Placeholder option mirrors the conversion - modal's hidden/disabled -999 sentinel option. */} + {/* Day pattern - required. Placeholder option uses the hidden/ + disabled sentinel option convention. */} - setDraft(d => ({ ...d, patternId: Number(e.target.value) })) + setDraft(d => ({ ...d, dayPatternId: Number(e.target.value) })) } invalid={isPatternInvalid} > - {/* Note — optional */} + {/* Note - optional */} setDraft(d => ({ ...d, note: e.target.value }))} /> - {/* Footer mirrors the Units modal: "Delete Unit" (danger, left) / - "Discard Changes" / Save (primary). Save is mode-dependent and - self-explanatory (meeting 4: every button should say the same, - self-explanatory thing). */} - {/* TODO(page-3): once Jason's groups reference instances, deleting an - instance that belongs to a group needs an interlock check (block or - cascade per team decision). */} {modalMode === 'edit' && ( From ffd1eb12ce935d0960addec7c74f07c903f4f804 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 29 Jul 2026 16:24:05 -0700 Subject: [PATCH 164/170] Fixed error code in Steve's email --- src/client/app/redux/selectors/adminSelectors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/app/redux/selectors/adminSelectors.ts b/src/client/app/redux/selectors/adminSelectors.ts index 4ec77073f9..442168711d 100644 --- a/src/client/app/redux/selectors/adminSelectors.ts +++ b/src/client/app/redux/selectors/adminSelectors.ts @@ -402,7 +402,7 @@ export const selectDefaultCreateWeekValues = createAppSelector<[], Omit Date: Sat, 1 Aug 2026 20:47:28 -0700 Subject: [PATCH 165/170] Adding tooltips, comments, and minor input validation to Holiday page --- .../app/components/TooltipHelpComponent.tsx | 1 + .../app/components/holiday/HolidayPage.tsx | 45 ++++++++++++++----- src/client/app/translations/data.ts | 9 ++-- src/server/routes/holidays.js | 20 +++++---- 4 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/client/app/components/TooltipHelpComponent.tsx b/src/client/app/components/TooltipHelpComponent.tsx index 4e9e20713f..c3ed0d0c49 100644 --- a/src/client/app/components/TooltipHelpComponent.tsx +++ b/src/client/app/components/TooltipHelpComponent.tsx @@ -35,6 +35,7 @@ export default function TooltipHelpComponent(props: TooltipHelpProps) { '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/` }, diff --git a/src/client/app/components/holiday/HolidayPage.tsx b/src/client/app/components/holiday/HolidayPage.tsx index f19ee3d894..3658ff7459 100644 --- a/src/client/app/components/holiday/HolidayPage.tsx +++ b/src/client/app/components/holiday/HolidayPage.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Alert, Button, Col, FormGroup, Input, Label, Row } from 'reactstrap'; +import { Alert, Button, Col, FormFeedback, FormGroup, Input, Label, Row } from 'reactstrap'; import { stableEmptyHolidays, useGetHolidaysQuery, @@ -12,8 +12,10 @@ import { useRefreshHolidaysMutation } from '../../redux/api/holidaysApi'; import { useTranslate } from '../../redux/componentHooks'; -import { titleStyle } from '../../styles/modalStyle'; +import { titleStyle, tooltipBaseStyle } from '../../styles/modalStyle'; import { showErrorNotification, showSuccessNotification } from '../../utils/notifications'; +import TooltipHelpComponent from '../TooltipHelpComponent'; +import TooltipMarkerComponent from '../TooltipMarkerComponent'; import HolidayViewComponent from './HolidayViewComponent'; /** @@ -29,6 +31,7 @@ export default function HolidayPage() { 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(); @@ -37,6 +40,14 @@ export default function HolidayPage() { 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 []; @@ -61,6 +72,7 @@ export default function HolidayPage() { 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 []; @@ -103,6 +115,7 @@ export default function HolidayPage() { }); }, [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(''); @@ -122,24 +135,21 @@ export default function HolidayPage() { 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 (countryCode.length === 0) { + if (!isCountryValid) { showErrorNotification(translate('holiday.import.country.required')); return; } - if (year.trim().length === 0) { + if (!isYearValid) { showErrorNotification(translate('holiday.year.invalid')); return; } const selectedYear = Number(year); - if (!Number.isInteger(selectedYear)) { - showErrorNotification(translate('holiday.year.invalid')); - return; - } - refreshHolidays({ country: countryCode, state: stateCode, @@ -154,6 +164,7 @@ export default function HolidayPage() { }); }; + // Display saved holidays for the current selection or an empty message. let holidayContent: React.ReactNode = null; if (countryCode.length > 0 && selectedHolidays.length === 0) { @@ -172,9 +183,13 @@ export default function HolidayPage() { return (
+

+
+ +

@@ -188,7 +203,9 @@ export default function HolidayPage() { ))} + + + @@ -257,9 +277,14 @@ export default function HolidayPage() { id='holiday-year' type='number' step='1' + required value={year} + invalid={!isYearValid} onChange={handleYearChange} /> + + + @@ -269,7 +294,7 @@ export default function HolidayPage() { color='primary' type='button' onClick={handleImport} - disabled={isLoading} + disabled={!isImportValid || isLoading} > diff --git a/src/client/app/translations/data.ts b/src/client/app/translations/data.ts index 2074d53d01..eb7710f534 100644 --- a/src/client/app/translations/data.ts +++ b/src/client/app/translations/data.ts @@ -332,6 +332,7 @@ const LocaleTranslationData = { "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.", @@ -430,7 +431,7 @@ const LocaleTranslationData = { "holiday.region.none": "None", "holiday.saved": "Saved Holidays", "holiday.state": "State/Province:", - "holiday.state.select": "Select a state or province", + "holiday.state.select": "No state or province", "holiday.type": "Type:", "holiday.type.bank": "Bank", "holiday.type.custom": "Custom", @@ -1078,6 +1079,7 @@ const LocaleTranslationData = { "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}", @@ -1176,7 +1178,7 @@ const LocaleTranslationData = { "holiday.region.unavailable": "Indisponible", "holiday.saved": "Saved Holidays\u{26A1}", "holiday.state": "State/Province:\u{26A1}", - "holiday.state.select": "Select a state or 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}", @@ -1824,6 +1826,7 @@ const LocaleTranslationData = { "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.", @@ -1922,7 +1925,7 @@ const LocaleTranslationData = { "holiday.region.unavailable": "No disponible", "holiday.saved": "Saved Holidays\u{26A1}", "holiday.state": "State/Province:\u{26A1}", - "holiday.state.select": "Select a state or 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}", diff --git a/src/server/routes/holidays.js b/src/server/routes/holidays.js index 930a984e8c..0c185f4a83 100644 --- a/src/server/routes/holidays.js +++ b/src/server/routes/holidays.js @@ -90,8 +90,9 @@ router.get('/', adminAuthMiddleware('get all holidays'), async (req, res) => { const rows = await Holiday.getAll(conn); res.json(rows.map(formatHolidayForResponse)); } catch (err) { - log.error(`Error while performing GET all holidays query: ${err}`); - res.sendStatus(500); + const errMsg = `Error while performing GET all holidays query: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); } }); @@ -148,8 +149,9 @@ router.get('/locations', adminAuthMiddleware('get holiday locations'), async (re regions }); } catch (err) { - log.error(`Error while getting holiday locations: ${err}`); - res.sendStatus(500); + const errMsg = `Error while getting holiday locations: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); } } }); @@ -221,8 +223,9 @@ router.post('/refresh', adminAuthMiddleware('refresh holidays'), async (req, res total: importedHolidays.length }); } catch (err) { - log.error(`Error while refreshing holidays: ${err}`); - res.sendStatus(500); + const errMsg = `Error while refreshing holidays: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); } } }); @@ -257,8 +260,9 @@ router.get('/:holidayId', adminAuthMiddleware('get holiday by id'), async (req, const row = await Holiday.getById(req.params.holidayId, conn); res.json(formatHolidayForResponse(row)); } catch (err) { - log.error(`Error while performing GET holiday by id query: ${err}`); - res.sendStatus(500); + const errMsg = `Error while performing GET holiday by id query: ${err}`; + log.error(errMsg); + failure(res, 500, errMsg); } } }); From e677f41fb4f3fa08516302ec6fd864efb527fc0b Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 29 Jul 2026 22:30:21 -0700 Subject: [PATCH 166/170] Fixed editing and saving web browser console error --- src/client/app/redux/api/holidayInstanceGroupsApi.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/app/redux/api/holidayInstanceGroupsApi.ts b/src/client/app/redux/api/holidayInstanceGroupsApi.ts index 151226bb87..4abe0982c3 100644 --- a/src/client/app/redux/api/holidayInstanceGroupsApi.ts +++ b/src/client/app/redux/api/holidayInstanceGroupsApi.ts @@ -116,7 +116,7 @@ export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ invalidatesTags: [{ type: 'HolidayInstanceGroups', id: 'LIST' }] }), - editHolidayInstanceGroup: builder.mutation({ + editHolidayInstanceGroup: builder.mutation({ async queryFn(group, _queryApi, _extraOptions, baseQuery) { const currentMembersResult = await baseQuery(`${membersUrl}/group/${group.id}`); if (currentMembersResult.error) { @@ -166,7 +166,7 @@ export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ } } - return { data: undefined }; + return { data: null }; }, invalidatesTags: (_result, _error, arg) => [ { type: 'HolidayInstanceGroups', id: arg.id }, @@ -174,7 +174,7 @@ export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ ] }), - deleteHolidayInstanceGroup: builder.mutation>({ + deleteHolidayInstanceGroup: builder.mutation>({ async queryFn({ id }, _queryApi, _extraOptions, baseQuery) { const currentMembersResult = await baseQuery(`${membersUrl}/group/${id}`); if (currentMembersResult.error) { @@ -210,7 +210,7 @@ export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ return { error: deleteGroupResult.error }; } - return { data: undefined }; + return { data: null }; }, invalidatesTags: [{ type: 'HolidayInstanceGroups', id: 'LIST' }] }) From 049787a23b04ca7fc43716f73daa4ed596096c16 Mon Sep 17 00:00:00 2001 From: JSmevog Date: Wed, 29 Jul 2026 22:33:12 -0700 Subject: [PATCH 167/170] added todo message --- src/client/app/redux/api/holidayInstanceGroupsApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/app/redux/api/holidayInstanceGroupsApi.ts b/src/client/app/redux/api/holidayInstanceGroupsApi.ts index 4abe0982c3..42a3c6eb4c 100644 --- a/src/client/app/redux/api/holidayInstanceGroupsApi.ts +++ b/src/client/app/redux/api/holidayInstanceGroupsApi.ts @@ -174,6 +174,7 @@ export const holidayInstanceGroupsApi = baseApi.injectEndpoints({ ] }), + //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}`); From 0a0b36b71b3006a191a4725dfe12588833cfc25c Mon Sep 17 00:00:00 2001 From: JSmevog Date: Sun, 2 Aug 2026 19:24:09 -0700 Subject: [PATCH 168/170] Renamed holiday group folder. Added success message notification after create and save --- src/client/app/components/RouteComponent.tsx | 2 +- ...eateHolidayInstanceGroupModalComponent.tsx | 0 ...EditHolidayInstanceGroupModalComponent.tsx | 0 .../HolidayInstanceGroupComponent.tsx | 27 +++++++++---------- .../HolidayInstanceGroupViewComponent.jsx | 0 src/client/app/translations/data.ts | 27 ++++++++++++------- 6 files changed, 32 insertions(+), 24 deletions(-) rename src/client/app/components/{holidayInstanceGroup => holiday-instance-groups}/CreateHolidayInstanceGroupModalComponent.tsx (100%) rename src/client/app/components/{holidayInstanceGroup => holiday-instance-groups}/EditHolidayInstanceGroupModalComponent.tsx (100%) rename src/client/app/components/{holidayInstanceGroup => holiday-instance-groups}/HolidayInstanceGroupComponent.tsx (87%) rename src/client/app/components/{holidayInstanceGroup => holiday-instance-groups}/HolidayInstanceGroupViewComponent.jsx (100%) diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index c83c6ea87d..34ae34476d 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -28,7 +28,7 @@ import NotFound from './router/NotFoundOutlet'; import RoleOutlet from './router/RoleOutlet'; import UnitsDetailComponent from './unit/UnitsDetailComponent'; import VisualUnitDetailComponent from './visual-unit/VisualUnitDetailComponent'; -import HolidayInstanceGroupComponent from './holidayInstanceGroup/HolidayInstanceGroupComponent'; +import HolidayInstanceGroupComponent from './holiday-instance-groups/HolidayInstanceGroupComponent'; import WeeksDetailComponent from './weeks/WeeksDetailComponent'; import HolidayInstancePage from './holiday-instances/HolidayInstancePage'; import HolidayPage from './holiday/HolidayPage'; diff --git a/src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx similarity index 100% rename from src/client/app/components/holidayInstanceGroup/CreateHolidayInstanceGroupModalComponent.tsx rename to src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx diff --git a/src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday-instance-groups/EditHolidayInstanceGroupModalComponent.tsx similarity index 100% rename from src/client/app/components/holidayInstanceGroup/EditHolidayInstanceGroupModalComponent.tsx rename to src/client/app/components/holiday-instance-groups/EditHolidayInstanceGroupModalComponent.tsx diff --git a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx similarity index 87% rename from src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx rename to src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx index 9fc1e6c412..0b5556cc93 100644 --- a/src/client/app/components/holidayInstanceGroup/HolidayInstanceGroupComponent.tsx +++ b/src/client/app/components/holiday-instance-groups/HolidayInstanceGroupComponent.tsx @@ -3,7 +3,6 @@ * 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 { useGetHolidayInstancesWithDetailsQuery @@ -20,6 +19,10 @@ 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[] = []; @@ -29,7 +32,6 @@ const stableEmptyHolidayInstanceDetails: HolidayInstanceDetails[] = []; */ export default function HolidayInstanceGroupComponent() { const translate = useTranslate(); - const [mutationError, setMutationError] = useState(''); const { data: holidayInstances = stableEmptyHolidayInstanceDetails, isLoading: holidayInstancesLoading, @@ -49,12 +51,12 @@ export default function HolidayInstanceGroupComponent() { holidayInstanceIds: number[], note: string ) => { - setMutationError(''); try { await addHolidayInstanceGroup({ name, holidayInstanceIds, note }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.create.success')); } catch (error) { - setMutationError( - `${translate('holiday.instance.group.create.failure')}${JSON.stringify(error)}` + showErrorNotification( + `${translate('holiday.instance.group.create.failure')}` ); } }; @@ -65,7 +67,6 @@ export default function HolidayInstanceGroupComponent() { holidayInstanceIds: number[], note: string ) => { - setMutationError(''); try { await editHolidayInstanceGroup({ id: holidayInstanceGroupId, @@ -73,20 +74,21 @@ export default function HolidayInstanceGroupComponent() { holidayInstanceIds, note }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.edit.success')); } catch (error) { - setMutationError( - `${translate('holiday.instance.group.edit.failure')}${JSON.stringify(error)}` + showErrorNotification( + `${translate('holiday.instance.group.edit.failure')}` ); } }; const handleDeleteHolidayInstanceGroup = async (holidayInstanceGroupId: number) => { - setMutationError(''); try { await deleteHolidayInstanceGroup({ id: holidayInstanceGroupId }).unwrap(); + showSuccessNotification(translate('holiday.instance.group.delete.success')); } catch (error) { - setMutationError( - `${translate('holiday.instance.group.delete.failure')}${JSON.stringify(error)}` + showErrorNotification( + `${translate('holiday.instance.group.delete.failure')}` ); } }; @@ -107,9 +109,6 @@ export default function HolidayInstanceGroupComponent() {
)} - {mutationError && ( -
{mutationError}
- )}
Date: Sun, 2 Aug 2026 20:39:13 -0700 Subject: [PATCH 169/170] Added comments and TODO's --- ...CreateHolidayInstanceGroupModalComponent.tsx | 5 +++++ .../EditHolidayInstanceGroupModalComponent.tsx | 17 +++++++++++++++++ .../HolidayInstanceGroupComponent.tsx | 7 +++++++ .../HolidayInstanceGroupViewComponent.jsx | 5 +++++ .../app/redux/api/holidayInstanceGroupsApi.ts | 6 ++++++ 5 files changed, 40 insertions(+) diff --git a/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx b/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx index 6ea6b22298..21376650b0 100644 --- a/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx +++ b/src/client/app/components/holiday-instance-groups/CreateHolidayInstanceGroupModalComponent.tsx @@ -47,6 +47,7 @@ export default function CreateHolidayInstanceGroupModalComponent( 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 @@ -101,6 +102,7 @@ export default function CreateHolidayInstanceGroupModalComponent( }; // 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; @@ -198,6 +200,9 @@ export default function CreateHolidayInstanceGroupModalComponent( {/* 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 */}