diff --git a/.eslintrc.js b/.eslintrc.js index 36e201e5d..73cc8cf0b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,7 @@ module.exports = { ], "rules": { "react/prop-types": 0, + "no-unused-vars": "warn", }, globals: { process: true, diff --git a/package.json b/package.json index 7ecbe37cb..89342795c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "is-url": "^1.2.4", "leaflet": "^1.7.1", "moment": "^2.29.1", + "moment-range": "^4.0.2", "oidc-client-ts": "^3.0.1", "proj4": "^2.7.2", "proj4leaflet": "^1.0.2", @@ -52,7 +53,10 @@ "reselect": "^4.1.7", "sass": "1.69.4", "semantic-ui-css": "^2.4.1", - "semantic-ui-react": "2.1.4" + "semantic-ui-react": "2.1.5", + "vis-data": "^7.1.9", + "vis-timeline": "^7.7.3", + "vis-util": "^5.0.7" }, "resolutions": { "crypto-js": "4.2.0", diff --git a/public/locales/fi/translation.json b/public/locales/fi/translation.json index d643fa60b..ded7ff6e9 100644 --- a/public/locales/fi/translation.json +++ b/public/locales/fi/translation.json @@ -74,8 +74,12 @@ "error": "Virhe: {{error}}", "warning": "Päivitettyjen aikataulutietojen tallentaminen saattaa kestää hieman pidempään kun Kaavapino tekee laskutoimituksia.", "estimated": "Kaavapinon arvio", - "project-stopped": "Projekti on toistaiseksi keskeytetty.", - "timeline-error": "Projekti ei ole ajan tasalla.", + "project-stopped": "Projekti on keskeytetty", + "project-stopped-text": "Projekti vastuuhenkilö on keskeyttänyt projektin toistaiseksi. Hän voi aktivoida projektin uudelleen muokkausnäkymästä.", + "timeline-error": "Projekti ei ole ajan tasalla", + "timeline-error-text": "Projektin vastuuhenkilön tulee päivittää aikataulu.", + "timelime-modal-error-text-1": "Seuraavien päivämäärien siirtäminen ei ole mahdollista, koska minimietäisyys viereisiin etappeihin on täyttynyt.", + "timelime-modal-error-text-2": "Siirrä ensiksi muita etappeja.", "months": { "jan": "Tammi", "feb": "Helmi", @@ -96,15 +100,28 @@ "shown": "nähtävillä", "reset-project-deadlines": "Tyhjennä aikataulut", "reset-confirm-dialog-title": "Aikataulujen uudelleen luominen", - "reset-confirm-dialog-question": "Oletko varma, että haluat luoda aikataulut uudestaan? Tapahtumaa ei voi perua." + "reset-confirm-dialog-question": "Oletko varma, että haluat luoda aikataulut uudestaan? Tapahtumaa ei voi perua.", + "dates-are-preliminary": "Aikataulutiedot ovat alustavia, kunnes ne on vahvistettu.", + "cancel-confirmation":"Peruuta päivämäärien vahvistus", + "confirm-dates":"Vahvista päivämäärät", + "dates-confirmed":"Aikataulutiedot on vahvistettu.", + "new-esillaolo":"Lisää uusi esilläolo", + "delete-esillaolo":"Poista esilläolo", + "delete-first-esillaolo":"Ensimmäistä esilläoloa ei voi poistaa.", + "delete-first-lautakunta":"Ensimmäistä lautakuntaa ei voi poistaa.", + "delete-first-nahtavillaolo":"Ensimmäistä nähtävilläoloa ei voi poistaa.", + "timeline-group-header": "Vaiheiden etapit", + "modify-timeline":"Muokkaa aikataulua" }, "common": { "cancel": "Peruuta", "save": "Tallenna", + "save-timeline":"Tallenna aikataulu", "continue": "Jatka", "person":"Henkilö", "unit":"Yksikkö tai tiimi", - "keyword":"Hakusana" + "keyword":"Hakusana", + "close":"Sulje" }, "messages": { "deadlines-successfully-saved": "Aikataulut tallennettu", @@ -143,7 +160,11 @@ "addfield":"Kentän lisäys.", "total":"Yhteensä", "fields":"kenttää.", - "general-save-error":"Tallennus epäonnistui" + "general-save-error":"Tallennus epäonnistui", + "error-with-dates":"Päivämäärät tarkistettu:", + "fixed-timeline-dates":"Päivämäärät muutettu:", + "checking-dates":"Tarkistetaan päivämääriä", + "dates-confirmed":"Päivämäärät validoitu" }, "floor-areas": { "title": "Päivitä kerrosalatiedot", @@ -238,7 +259,10 @@ "error-prevent-add":"Virhe lomakkeella estää lisäyksen", "fieldset-info":"Yhteensä {{fieldAmount}} tietuetta.", "no-fields":"Suodatinvalinnalla ei löytynyt tämän otsikon alta suodatettavia kenttiä.", - "link-to-map":"Linkki projektin karttapalveluun." + "link-to-map":"Linkki projektin karttapalveluun.", + "add-new-board":"Lisää uusi lautakunta", + "add-new-presence":"Lisää uusi esilläolo", + "add-new-review": "Lisää uusi nähtävilläolo" }, "nav-header": { "latest-update": "Viimeisin muokkaus: {{latestUpdate}}", diff --git a/src/actions/projectActions.js b/src/actions/projectActions.js index 5e81949ee..7bb2a4d8a 100644 --- a/src/actions/projectActions.js +++ b/src/actions/projectActions.js @@ -104,7 +104,56 @@ export const FORM_ERROR_LIST = "formErrorList" export const RESET_FORM_ERRORS = "resetFormErrors" export const GET_ATTRIBUTE_DATA = "getAttributeData" export const SET_ATTRIBUTE_DATA = "setAttributeData" - +export const FETCH_DISABLED_DATES_START = 'fetchDisabledDatesStart'; +export const FETCH_DISABLED_DATES_SUCCESS = 'fetchDisabledDatesSuccess'; +export const FETCH_DISABLED_DATES_FAILURE = 'fetchDisabledDatesFailure'; +export const VALIDATE_DATE = 'validateDate'; +export const SET_DATE_VALIDATION_RESULT = 'setDateValidationResult'; +export const REMOVE_DEADLINES = 'removeDeadlines'; +export const UPDATE_DATE_TIMELINE = "updateDateTimeline" +export const RESET_ATTRIBUTE_DATA = "resetAttributeData" +export const VALIDATE_PROJECT_TIMETABLE = "validateProjectTimetable" +export const UPDATE_PROJECT_FAILURE = 'updateProjectFailure'; + +export const updateProjectFailure = (errorData) => ({ + type: UPDATE_PROJECT_FAILURE, + payload: errorData, +}); +export const validateProjectTimetable = () => ({ + type: VALIDATE_PROJECT_TIMETABLE +}); +export const resetAttributeData = (initialData) => ({ + type: RESET_ATTRIBUTE_DATA, + payload: {initialData}, +}); +export const updateDateTimeline = (field, newDate, formValues, isAdd, deadlineSections) => ({ + type: UPDATE_DATE_TIMELINE, + payload: { field, newDate, formValues, isAdd, deadlineSections}, +}); +export const removeDeadlines = (deadlines) => ({ + type: REMOVE_DEADLINES, + payload: deadlines, +}); +export const setDateValidationResult = (valid,result) => ({ + type: SET_DATE_VALIDATION_RESULT, + payload: {valid,result} +}); +export const validateDateAction = (field,projectName,date) => ({ + type: VALIDATE_DATE, + payload: {field,projectName,date} +}); +export const fetchDisabledDatesStart = (startDate, endDate) => ({ + type: FETCH_DISABLED_DATES_START, + payload: { startDate, endDate }, +}); +export const fetchDisabledDatesSuccess = (disabledDates) => ({ + type: FETCH_DISABLED_DATES_SUCCESS, + payload: disabledDates, +}); +export const fetchDisabledDatesFailure = (error) => ({ + type: FETCH_DISABLED_DATES_FAILURE, + payload: error, +}); export const setAttributeData = (fieldName,data,formName, set, nulledFields,i) =>({ type: SET_ATTRIBUTE_DATA, payload:{fieldName,data,formName, set, nulledFields,i} diff --git a/src/assets/icons/alert-icon-yellow.svg b/src/assets/icons/alert-icon-yellow.svg new file mode 100644 index 000000000..03320ee9c --- /dev/null +++ b/src/assets/icons/alert-icon-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/angle-right.svg b/src/assets/icons/angle-right.svg new file mode 100644 index 000000000..f0fda1247 --- /dev/null +++ b/src/assets/icons/angle-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/checkmark-icon-green.svg b/src/assets/icons/checkmark-icon-green.svg new file mode 100644 index 000000000..097504de2 --- /dev/null +++ b/src/assets/icons/checkmark-icon-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/icon-error-red.svg b/src/assets/icons/icon-error-red.svg new file mode 100644 index 000000000..2ddcc1e09 --- /dev/null +++ b/src/assets/icons/icon-error-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/lock-blue.svg b/src/assets/icons/lock-blue.svg new file mode 100644 index 000000000..e213697b6 --- /dev/null +++ b/src/assets/icons/lock-blue.svg @@ -0,0 +1,8 @@ + + + UI/Actions and settings/lock + + + + + \ No newline at end of file diff --git a/src/assets/icons/lock-open-blue.svg b/src/assets/icons/lock-open-blue.svg new file mode 100644 index 000000000..27566c6cc --- /dev/null +++ b/src/assets/icons/lock-open-blue.svg @@ -0,0 +1,8 @@ + + + UI/Actions and settings/lock-open + + + + + \ No newline at end of file diff --git a/src/assets/icons/lock-open.svg b/src/assets/icons/lock-open.svg new file mode 100644 index 000000000..cbb758db6 --- /dev/null +++ b/src/assets/icons/lock-open.svg @@ -0,0 +1,8 @@ + + + UI/Actions and settings/lock-open + + + + + \ No newline at end of file diff --git a/src/assets/icons/lock.svg b/src/assets/icons/lock.svg new file mode 100644 index 000000000..4bd53adce --- /dev/null +++ b/src/assets/icons/lock.svg @@ -0,0 +1,8 @@ + + + UI/Actions and settings/lock + + + + + \ No newline at end of file diff --git a/src/assets/icons/plus-blue.svg b/src/assets/icons/plus-blue.svg new file mode 100644 index 000000000..fd97d8a9e --- /dev/null +++ b/src/assets/icons/plus-blue.svg @@ -0,0 +1,8 @@ + + + UI/Arrows and operators/plus + + + + + \ No newline at end of file diff --git a/src/assets/icons/plus.svg b/src/assets/icons/plus.svg new file mode 100644 index 000000000..158c6e813 --- /dev/null +++ b/src/assets/icons/plus.svg @@ -0,0 +1,8 @@ + + + UI/Arrows and operators/plus + + + + + \ No newline at end of file diff --git a/src/assets/icons/trash-blue.svg b/src/assets/icons/trash-blue.svg new file mode 100644 index 000000000..b16caf352 --- /dev/null +++ b/src/assets/icons/trash-blue.svg @@ -0,0 +1,8 @@ + + + UI/Forms and information/trash + + + + + \ No newline at end of file diff --git a/src/assets/icons/trash-grey.svg b/src/assets/icons/trash-grey.svg new file mode 100644 index 000000000..7d2f21d0d --- /dev/null +++ b/src/assets/icons/trash-grey.svg @@ -0,0 +1,8 @@ + + + UI/Forms and information/trash + + + + + \ No newline at end of file diff --git a/src/components/ProjectTimeline/AddGroupModal.js b/src/components/ProjectTimeline/AddGroupModal.js new file mode 100644 index 000000000..05cc45aed --- /dev/null +++ b/src/components/ProjectTimeline/AddGroupModal.js @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import { change } from 'redux-form' +import { useDispatch } from 'react-redux'; +import { EDIT_PROJECT_TIMETABLE_FORM } from '../../constants' +import { IconPlus,Button } from 'hds-react' +import { useTranslation } from 'react-i18next' +import PropTypes from 'prop-types' + +const AddGroupModal = ({toggleOpenAddDialog,addDialogStyle,addDialogData,closeAddDialog, allowedToEdit, isAdmin, timelineAddButton}) => { + const {t} = useTranslation() + const dispatch = useDispatch(); + + const addNew = (addedKey) => { + if(addedKey){ + dispatch(change(EDIT_PROJECT_TIMETABLE_FORM, addedKey, true)); + if (addedKey.includes("jarjestetaan_ehdotus_esillaolo")) { + const parts = addedKey.split("_"); + const index = "_"+ (parseInt(parts[parts.length - 1], 10)); + dispatch(change(EDIT_PROJECT_TIMETABLE_FORM, "kaavaehdotus_uudelleen_nahtaville"+index.toString(), true)); + } + closeAddDialog() + } + else{ + closeAddDialog() + } + } + + useEffect(() => { + if (timelineAddButton) { + if (toggleOpenAddDialog) { + timelineAddButton.classList.add("menu-open"); + } else { + const timelineAddButtons = document.getElementsByClassName('timeline-add-button'); + Array.from(timelineAddButtons).forEach(button => { + button.classList.remove("menu-open"); + }); + } + } + }, [toggleOpenAddDialog]) + + return ( +
+ {!addDialogData.hidePresence && + <> + + {addDialogData.esillaoloReason && ( + + {addDialogData.group.id === "Ehdotus" ? + (addDialogData.esillaoloReason === "noconfirmation" ? "Kaavoitussihteerin tulee vahvistaa aikaisempi nähtävilläolo, jonka jälkeen voidaan lisätä uusi." : "Nähtävilläolojen maksimimäärä on saavutettu.") : + (addDialogData.esillaoloReason === "noconfirmation" ? "Kaavoitussihteerin tulee vahvistaa aikaisempi esilläolo, jonka jälkeen voidaan lisätä uusi." : "Esilläolojen maksimimäärä on saavutettu.") + } + + )} + + } + {!addDialogData.hideBoard && + <> + + {addDialogData.lautakuntaReason && {addDialogData.lautakuntaReason === "noconfirmation" ? "Kaavoitussihteerin tulee vahvistaa aikaisempi lautakunta, jonka jälkeen voidaan lisätä uusi." : "Lautakuntien maksimimäärä on saavutettu."}} + + } +
+ ); +} + +AddGroupModal.propTypes = { + toggleOpenAddDialog: PropTypes.bool, + addDialogStyle: PropTypes.object, + addDialogData: PropTypes.object, + closeAddDialog: PropTypes.func, + allowedToEdit: PropTypes.bool, + isAdmin: PropTypes.bool, + timelineAddButton: PropTypes.instanceOf(HTMLElement) +}; + +export default AddGroupModal; \ No newline at end of file diff --git a/src/components/ProjectTimeline/ProjectTimeline.jsx b/src/components/ProjectTimeline/ProjectTimeline.jsx index 75a7e0ce7..d51275f33 100644 --- a/src/components/ProjectTimeline/ProjectTimeline.jsx +++ b/src/components/ProjectTimeline/ProjectTimeline.jsx @@ -1,17 +1,19 @@ import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types'; +import { Notification } from 'hds-react' import './ProjectTimeline.scss' import { createMonths } from './helpers/createMonths' import { createDeadlines } from './helpers/createDeadlines' import { connect } from 'react-redux' import { getProject, getProjectSuccessful } from '../../actions/projectActions' import { findWeek } from './helpers/helpers' -import { IconError } from 'hds-react' import { useTranslation } from 'react-i18next' import dayjs from 'dayjs' +import { getVisibilityBoolName } from '../../utils/projectVisibilityUtils'; +import { attributeDataSelector } from '../../selectors/projectSelector'; function ProjectTimeline(props) { - const { deadlines, projectView, onhold } = props - + const { deadlines, projectView, onhold, attribute_data } = props const { t } = useTranslation() const [showError, setShowError] = useState(false) const [drawMonths, setDrawMonths] = useState([]) @@ -31,18 +33,42 @@ function ProjectTimeline(props) { 11: t('deadlines.months.dec') } useEffect(() => { + const filteredDeadlines = filterVisibleDeadlines(deadlines, attribute_data) if (!projectView) { - const months = createMonths(deadlines) + const months = createMonths(filteredDeadlines) createDrawMonths(months.months) } else { - createTimelineItems(deadlines) + createTimelineItems(filteredDeadlines) } - }, []) + }, []); + useEffect(() => { - if (deadlines) { - createTimelineItems(deadlines) + const filteredDeadlines = filterVisibleDeadlines(deadlines, attribute_data) + if (filteredDeadlines) { + createTimelineItems(filteredDeadlines) } - }, [deadlines]) + }, [deadlines]); + + function filterVisibleDeadlines(deadlineArray, attributeData) { + return deadlineArray.filter((deadline) => { + const group = deadline?.deadline?.deadlinegroup; + if (!group) { + // Phase start/end dates have no group; this is ok. + return true; + } + const visBool = getVisibilityBoolName(group); + if (!visBool) { + // deadlines with no visibility bool should be shown by default + return true; + } + // Special cases where bool is missing from attributeData + if (['oas_esillaolokerta_1','ehdotus_nahtavillaolokerta_1','tarkistettu_ehdotus_lautakuntakerta_1'].includes(group)){ + return true; + } + return attributeData ? attributeData[visBool] : false; + }); + } + function createNowMarker(week) { let nowMarker = [] for (let i = 1; i <= 5; i++) { @@ -343,20 +369,18 @@ function ProjectTimeline(props) { const containerClass = onhold || showError ? 'timeline-graph-container hide-background' - : 'timeline-graph-container' - return ( + : 'timeline-graph-container'; + return (
{onhold ? ( -
- - {t('deadlines.project-stopped')} -
+ +

{t('deadlines.project-stopped-text')}

+
) : null} {showError && !onhold ? ( -
- - {t('deadlines.timeline-error')} -
+ +

{t('deadlines.timeline-error-text')}

+
) : null}
{ + return { + attribute_data: attributeDataSelector(state) + } +} + +ProjectTimeline.propTypes= { + deadlines: PropTypes.array, + projectView: PropTypes.bool, + onhold: PropTypes.bool, + attribute_data: PropTypes.object, + +} + +export default connect(mapStateToProps, mapDispatchToProps)(ProjectTimeline) diff --git a/src/components/ProjectTimeline/ProjectTimeline.scss b/src/components/ProjectTimeline/ProjectTimeline.scss index 8a53e744e..a8751e9de 100644 --- a/src/components/ProjectTimeline/ProjectTimeline.scss +++ b/src/components/ProjectTimeline/ProjectTimeline.scss @@ -14,46 +14,51 @@ margin-left: -160px; left: 50%; top: 20px; - width: 320px; - height: 56px; - border-left: 4px solid #d8000c; - background-color: #ff8179; - text-align: center; - svg { - position: absolute; - top: 10px !important; - left: 26px !important; - } - span { - position: absolute; - top: 18px; - left: 68px; - opacity: 1; - } + width: 336px; + height: 119px; } .timeline-onhold-message { position: absolute; z-index: 4; margin-left: -160px; - left: 50%; - top: 10px; - width: 320px; - height: 56px; - border-left: 4px solid $color-warning; - background-color: $color-warning2; + left: 40%; + top: 20px; + width: 603px; + height: 120px; + border-left: 8px solid #D18200; + background-color: #FFF4B4; text-align: center; + font-size: $size18; + font-weight: 700; - svg { - position: absolute; - top: 10px !important; - left: 26px !important; - } span { position: absolute; top: 18px; - left: 68px; + left: 48px; opacity: 1; } + p { + font-size: $size14; + font-weight: 400; + position: absolute; + top: 55px; + left: 16px; + width: 80%; + text-align: start; + } + .alert-icon-yellow { + z-index: 1000; + position: absolute; + left: 16px; + top: 16px; + padding: 0; + width: 24px; + height: 24px; + background: url('../../assets/icons/alert-icon-yellow.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size24; + } } .timeline-load-project-message { diff --git a/src/components/ProjectTimeline/TimelineModal.js b/src/components/ProjectTimeline/TimelineModal.js new file mode 100644 index 000000000..6c6b75b62 --- /dev/null +++ b/src/components/ProjectTimeline/TimelineModal.js @@ -0,0 +1,257 @@ +import React from 'react'; +import { Modal } from 'semantic-ui-react' +import { Button,Tabs,IconCross } from 'hds-react' +import { EDIT_PROJECT_TIMETABLE_FORM } from '../../constants' +import FormField from '../input/FormField' +import { isArray } from 'lodash' +import { showField } from '../../utils/projectVisibilityUtils' +import textUtil from '../../utils/textUtil' +import objectUtil from '../../utils/objectUtil'; +import PropTypes from 'prop-types' +import './VisTimeline.css' + +const TimelineModal = ({ open,group,content,deadlinegroup,deadlines,openDialog,visValues,deadlineSections,formSubmitErrors,projectPhaseIndex,archived,allowedToEdit,disabledDates,lomapaivat,dateTypes,groups, items, sectionAttributes }) => { + + const getAttributeValues = (attributes) => { + return Object.values(attributes).flatMap((v) => Object.values(v)); + }; + + const findLabel = (fieldName, attributeValues) => { + const field = attributeValues.find((value) => value?.name === fieldName); + return field ? field.label : null; + }; + + const getErrorLabel = (fieldName) => { + const attributeValues = deadlineSections.flatMap((deadline_section) => + deadline_section.sections.flatMap((section) => getAttributeValues(section.attributes)) + ); + + const label = findLabel(fieldName, attributeValues); + return {label}: ; + }; + + const renderSubmitErrors = () => { + const keys = formSubmitErrors ? Object.keys(formSubmitErrors) : [] + return keys.map(key => { + const errors = formSubmitErrors[key] + if (Array.isArray(errors)) { + return ( +
+ {getErrorLabel(key)} + {errors?.map(error => ( + {error} + ))} +
+ ) + } + }) + } + + let currentSubmitErrors = Object.keys(formSubmitErrors).length > 0 + + const getFormField = (fieldProps, key, disabled, deadlineSection, maxMoveGroup, maxDateToMove, title, confirmedValue, type) => { + if (!showField(fieldProps.field, visValues)) { + return null + } + //Hide ehdotus_nahtaville_aineiston_maaraaika if kaavaprosessin_kokoluokka is L or XL + // TODO: should be fixed in backend or Excel, remove this when mistake is found there + if(fieldProps.field.name.includes("ehdotus_nahtaville_aineiston_maaraaika") && (visValues['kaavaprosessin_kokoluokka'] === 'L' || visValues['kaavaprosessin_kokoluokka'] === 'XL')){ + return null + } + + const error = formSubmitErrors?.[fieldProps?.field?.name]; + let className = ''; + + if (error !== undefined) { + className = 'modal-field error-border' + } else { + className = 'modal-field' + } + // Special case since label is used. + if (fieldProps.field.display === 'checkbox') { + className = error ? 'error-border' : '' + } + + let modifiedError = '' + if (isArray(error)) { + error.forEach(current => { + modifiedError = modifiedError + ' ' + current + }) + } else { + modifiedError = error + } + + return ( + <> + + {modifiedError &&
{modifiedError}
} + + ) + } + const getFormFields = (sections, sectionIndex, disabled, deadlineSection, maxMoveGroup, maxDateToMove, title, confirmedValue) => { + // Separate the section with the label "Mielipiteet viimeistään" + const filteredSections = sections.filter(section => section.label !== "Mielipiteet viimeistään"); + const lastSection = sections.find(section => section.label === "Mielipiteet viimeistään"); + + // If the section exists, push it to the end of the filteredSections array + if (lastSection) { + filteredSections.push(lastSection); + } + + const formFields = [] + filteredSections.forEach((field, fieldIndex) => { + formFields.push(getFormField({ field }, `${sectionIndex} - ${fieldIndex}`, {disabled}, {deadlineSection}, maxMoveGroup, maxDateToMove, title, confirmedValue, field?.type)) + }) + return formFields + } + + const getMaxiumDateToMove = (attr) => { + const foundGroups = groups.filter(g => g.nestedInGroup === group); + const esillaolo = foundGroups.filter(obj => obj.content.startsWith('Esilläolo-')); + const nahtavillaolo = foundGroups.filter(obj => obj.content.startsWith('Nahtavillaolo-')); + const lautakunta = foundGroups.filter(obj => obj.content.startsWith('Lautakunta-')); + + let latestGroup + let latestObject + let miniumObject + + const phaseObject = lautakunta.length != 0 && lautakunta.length >= esillaolo.length || lautakunta.length != 0 && lautakunta.length >= nahtavillaolo.length ? lautakunta : esillaolo.length > 0 ? esillaolo : nahtavillaolo + latestGroup = objectUtil.getHighestNumberedObject(phaseObject,groups); + latestObject = attr[latestGroup?.deadlinegroup] + if(latestObject){ + miniumObject = objectUtil.getMinObject(latestObject) + if(visValues[miniumObject]){ + return [visValues[miniumObject], latestGroup.content] + } + } + return [null,null] + } + + const renderSection = (section,sectionIndex,title) => { + //grouped_sections specific to timeline with groups and subgroups + const sections = section?.grouped_sections + const splitTitle = title.split('-').map(part => part.toLowerCase()) + splitTitle[1] = splitTitle[1]?.trim() === "1" ? "" : "_"+splitTitle[1]?.trim() + let confirmedValue + if(group === "Ehdotus" && splitTitle[0].trim() === "nähtävilläolo"){ + splitTitle[0] = "esillaolo" + confirmedValue = "vahvista_"+group.toLowerCase()+"_"+splitTitle[0]+splitTitle[1] + } + else if(group === "Tarkistettu ehdotus" && splitTitle[0].trim() === "lautakunta"){ + confirmedValue = "vahvista_"+"tarkistettu_ehdotus_"+"lautakunnassa"+splitTitle[1] + } + else if(group !== "Tarkistettu ehdotus" && splitTitle[0].trim() === "lautakunta"){ + if(group === "Luonnos" || group === "Ehdotus"){ + confirmedValue = "vahvista_"+"kaava"+group.toLowerCase()+"_"+splitTitle[0]+splitTitle[1] + } + else{ + confirmedValue = "vahvista_"+group.toLowerCase()+"_"+splitTitle[0]+splitTitle[1] + } + // Replace 'lautakunta' with 'lautakunnassa' + confirmedValue = confirmedValue.replace('lautakunta', 'lautakunnassa'); + } + else{ + confirmedValue = "vahvista_"+group.toLowerCase()+"_"+splitTitle[0]+"_alkaa"+splitTitle[1] + confirmedValue = textUtil.replaceScandics(confirmedValue) + } + confirmedValue = confirmedValue.replace(/\s+/g, ''); + const isConfirmed = visValues[confirmedValue] + const disabled = archived || isConfirmed ? true : sectionIndex < projectPhaseIndex + const renderedSections = [] + sections.forEach(subsection => { + const attr = subsection?.attributes + const [maxDateToMove,maxMoveGroup] = getMaxiumDateToMove(attr) + if(attr[deadlinegroup]){ + renderedSections.push( + + + {Object.keys(attr[deadlinegroup]).map((key) => { + let tabContent = key === "default" ? content : key + if (key === "Esille") tabContent = "Päivämäärät" + return {tabContent} + })} + + {Object.values(attr[deadlinegroup]).map((subsection, index) => { + return + {getFormFields(subsection, sectionIndex, disabled, attr[deadlinegroup], maxMoveGroup, maxDateToMove, title, confirmedValue)} + + })} + + ) + } + }) + return renderedSections + } + + return ( + +
+ {open &&
} +
+ + + + +
+ {deadlineSections.map((section, i) => { + if (section.title === group) { + return renderSection(section, i, content); + } + })} +
+
+
+
+
+ ); + } + + TimelineModal.propTypes = { + open: PropTypes.bool, + group: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + content: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + deadlinegroup: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + deadlines: PropTypes.array, + openDialog: PropTypes.func, + visValues: PropTypes.object, + deadlineSections: PropTypes.array, + formSubmitErrors: PropTypes.object, + projectPhaseIndex: PropTypes.number, + archived: PropTypes.bool, + allowedToEdit: PropTypes.bool, + disabledDates: PropTypes.array, + lomapaivat: PropTypes.array, + dateTypes: PropTypes.object, + groups: PropTypes.array, + items: PropTypes.array, + sectionAttributes: PropTypes.array + }; + + export default TimelineModal \ No newline at end of file diff --git a/src/components/ProjectTimeline/VisTimeline.scss b/src/components/ProjectTimeline/VisTimeline.scss new file mode 100644 index 000000000..e81c57c24 --- /dev/null +++ b/src/components/ProjectTimeline/VisTimeline.scss @@ -0,0 +1,820 @@ +@import '../common/colors'; + +.vis{ + margin-top: 0; + margin-bottom: 0; + border-color: RGBA(0,0,0,0); + .vis-left{ + width: 300px; + .vis-content { + top: 0 !important; + } + } + .vis-timeline .vis-content { + transform: translateY(0px) !important; + } + .vis-timeline { + border-top: none; + border-bottom: none; + border-color: #ccc; + overflow-y: visible !important; + overflow-x: clip !important; + .vis-vertical .vis-current-time { + top: 84px !important; + } + .vis-center, .vis-left { + border-bottom: none + } + } + + .vis-panel.vis-top { + border: 1px solid $color-black-20; + border-top: none; + height: 85px; + position: sticky; + top: 81px !important; + left: 301px !important; + margin-right: -3.5px; + z-index: 1; + } + + .vis-panel.vis-center .vis-content { + .vis-itemset { + height: auto !important; + min-height: 57.5vh !important; + } + } + + .vis-time-axis.vis-foreground{ + background-color: $dropdown-divider-color; + left: -0.5px !important; + height: 85px !important; + & > div { + color: $black1; + font-size: $size14; + } + } + + .timeline-menu-container { + padding-top: 20px; + position: sticky; + top: 0; + background-color: #ffffff; + z-index: 1000; + border-bottom: 1px solid $color-black-20; + } + .timeline-menu { + display: flex; + justify-content: flex-start; + align-items: center; + .time-menu{ + padding-left: 300px; + button { + --min-size: $size32; //hds syntax + width: $size44; + height: $size44; + padding: 0; // Add this line + box-sizing: border-box; // Add this line + margin-bottom: $size16; + position: relative; + &:nth-child(2) { + margin-left: -2px; + } + svg { + margin-top: 3px; + } + &:hover { + border-color: $color-bus; + } + &:focus-visible, &:active { + outline: 2px solid $color-coat-of-arms; + outline-offset: 2px; + } + } + } + .today-menu{ + button { + --min-size: $size32; //hds syntax + height: $size44; + font-family: 'Helsinki Grotesk Medium', sans-serif; + color: $color-bus; + padding: 0; + box-sizing: border-box; + margin-bottom: $size16; + margin-left: $size16; + font-weight: 500; + line-height: $size24; + text-align: left; + position: relative; + &::after { + content: ''; + width: 0; + height: 32px; + border: none; + border-left: 1px solid #cccccc; + position: absolute; + right: -33.5px; + top: 50%; + transform: translateY(-50%); + } + &:hover { + border-color: $color-bus; + } + &:focus-visible, &:active { + outline: 3px solid $color-coat-of-arms; + outline-offset: 2px; + } + } + } + .zoom-menu{ + margin-left: 62px; + button{ + --min-size: $size32; //hds syntax + height: $size32; + background: $dropdown-divider-color; + color: $black1; + border: 1px #1A1A1A solid; + border-radius: $size16; + margin-bottom: $size16; + margin-right: $size16; + &:hover { + background-color: $color-black-20; + } + &:focus-visible, &:active { + outline: 2px solid $color-coat-of-arms; + outline-offset: 2px; + } + &::after { + border: none; + } + } + button:active, .selected { + --min-size: $size32; //hds syntax + height: $size32; + background: $color-black-20; + color: $black1; + border: 1px #1A1A1A solid; + border-radius: $size16; + outline-offset: 2px; + margin-bottom: $size16; + margin-right: $size16; + } + span { + font-family: 'Helsinki Grotesk Medium', sans-serif; + font-weight: 500; + font-size: $size14; + line-height: $size24; + } + } + } + + .vis-item:not(.inner-end){ + border:none; + } + + .vis-item.vis-dot { + &.past { + background: $color-gray; + } + } + + .inner-end{ + z-index: 1000; + pointer-events: auto; + border-width: 0; + height: $size24; + border-radius: 15px; + top: 5.5px !important; + .vis-item-overflow{ + border: 2px solid #000000; + background: #fff; + border-radius: 15px; + .vis-item-content{ + pointer-events: auto; + padding:0; + } + } + &.past .vis-item-overflow { + background: $color-gray; + } + } + + .board.deadline { + height: $size24 !important; + width: $size24 !important; + top: -4px !important; + left: -12px !important; + max-width: 100%; + display: inline-block; + border: 2px solid #1a1a1a; + border-radius: 14px; + padding: 5px; + background-color: #fff; + z-index: 1500; + .vis-dot { + display: none; + } + &.past { + background-color: $color-gray; + } + } + .board.board-date { + height: 1.3rem !important; + width: 1.3rem !important; + top: -5px !important; + left: -12px !important; + max-width: 100%; + right: 0; + display: inline-block; + border: $size1 solid #fff; + border-radius: 14px; + padding: 5px; + background-color: #1a1a1a; + box-shadow: 0 0 0 2px #1a1a1a; + z-index: 1500; + .vis-dot { + display: none; + } + &.past { + outline: 1px solid $color-gray; + outline-offset: -1px; + background-color: #1a1a1a !important; + } + &.confirmed::after { + content: ""; + position: absolute; + top: -9px; + right: -10px; + width: 21px; + height: 21px; + background: #fff url('../../assets/icons/checkmark-icon-green.svg') no-repeat; + background-position: center; + background-size: contain; + border-radius: 50%; + } + } + + .phase-length{ + z-index: 1000; + top: 5.5px !important; + pointer-events: auto; + border-width: 0; + height: $size24; + .vis-item-overflow{ + border: 2px solid #000000; + background: #fff; + border-radius: 6px; + .vis-item-content{ + pointer-events: auto; + padding:0; + } + } + &.past .vis-item-overflow { + background: $color-gray; + } + } + + .vis-dot-board{ + position: absolute; + padding: 5px; + border-width: 1px !important; + border-style: solid; + border-radius: 8px; + background-color: #1a1a1a; + border-color: #fff; + box-shadow: 0 0 0 2px #1a1a1a; + } + + .divider{ + position: absolute; + background-color: #1a1a1a; + border-color: #1a1a1a; + height: 4px; + top: 15.5px !important; + z-index: 100; + } + + .vis-point { + top: -4.5px !important; + left: -10px; + border: 2px solid #000000; + background-color: #fff; + height: $size24; + width: $size24; + z-index: 200; + &.past { + background: $color-gray; + } + } + + .vis-dot.board-only{ + border: 0.063rem solid #fff; + border-radius: 14px; + padding: 5px; + background-color: #1a1a1a; + box-shadow: 0 0 0 2px #1a1a1a; + height: $size20; + width: $size20; + } + + .vis-range{ + border-color: #1a1a1a; + &.confirmed::after { + content: ""; + position: absolute; + top: -9px; + right: -10px; + width: 21px; + height: 21px; + background: #fff url('../../assets/icons/checkmark-icon-green.svg') no-repeat; + background-position: center; + background-size: contain; + border-radius: 50%; + } + &.confirmed.board::after { + top: -18px; + right: -20px; + } + } + + .locked{ + background-color: rgba(255, 0, 0, 0.2); + } + + .vis-ltr .vis-label.vis-nested-group .vis-inner { + padding-left: $size10; + } + + .hiddenTimes{ + display: none; + } + + .vis-nested-group.vis-group-level-unknown-but-gte1{ + border-top: none; + border-left: none; + border-right: none; + background: #ffffff !important; + height: 35px !important; + .vis-inner{ + padding: 0; + display: flex; + justify-content: flex-start; + align-items: center; + height: 100%; + .timeline-buttons-container{ + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; + height: 100%; + padding-left: $size15; + position: relative; + .timeline-button-label{ + font-weight: 400; + padding-top: 5px; + } + .timeline-edit-button{ + position: absolute; + margin-left: -40px; + padding: 0; + width: 70%; + height: 100%; + background: transparent; + background-position-x: center; + background-position-y: center; + background-size: 1.125rem; + border: none; + cursor: pointer; + } + .timeline-remove-button{ + display: none; + padding: 0; + margin-left: auto; + width: 24px; + height: 24px; + background:url('../../assets/icons/trash-red.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size24; + border: none; + cursor: pointer; + &.button-disabled{ + background:url('../../assets/icons/trash-grey.svg') no-repeat; + } + } + .timeline-lock-button{ + visibility: hidden; + padding: 0; + margin-right: 12px; + /* margin-left: 12px; + width: 24px; + height: 24px; Hidden for now, unhide when lock feature in dev*/ + margin-left: 0; + width: 0; + height: 0; + background:url('../../assets/icons/lock-open-blue.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size24; + border: none; + cursor: pointer; + } + .lock{ + margin-right: 12px; + margin-left: 12px; + padding: 0; + width: 24px; + height: 24px; + background:url('../../assets/icons/lock-blue.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size24; + border: none; + cursor: pointer; + } + &:focus { + .timeline-remove-button{ + display: block; + } + .timeline-lock-button{ + display: block; + } + } + .timeline-remove-text { + display: none; + position: absolute; + top: 35px; + right: 1px; + background-color: #ffffff; + width: 177px; + height: auto; + padding: 8px; + box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.6); + z-index: 1005; + font-size: $size14; + font-weight: 400; + } + .timeline-remove-button.button-disabled:hover + .timeline-remove-text { + display: block; + position: absolute; + } + } + .show-buttons{ + .timeline-remove-button{ + display: block; + } + .timeline-lock-button{ + display: block; + } + .button-disabled:not(.timeline-remove-button){ + pointer-events: auto; + cursor: default; + opacity: 0.5; + color: gray; + } + .button-enabled{ + pointer-events: all; + cursor: pointer; + opacity: 1; + } + } + .highlight-selected{ + .timeline-remove-button{ + display: block; + margin-top: -3px; + } + .timeline-lock-button{ + display: block; + margin-top: -3px; + margin-right: 10px; + margin-left: 12px; + } + } + } + } + + .vis-nested-group.vis-group-level-unknown-but-gte1.highlight-selected{ + border: 2px solid $color-bus; + background-color: $color-bus-light !important; + .vis-inner .timeline-buttons-container .timeline-button-label { + padding-top: 0; + margin-left: -2px; + } + } + + .vis-labelset{ + padding-bottom: 50px; + .vis-label{ + .vis-inner{ + height: $size32; + display: flex; + justify-content: flex-start; + align-items: center; + } + } + } + + .vis-label{ + background-color: #F2F2F2; + border-bottom: 1px solid #CCCCCC; + } + + .vis-label.vis-nested-group{ + padding-left: 15px; + } + + .vis-group-level-0:not(.vis-nested-group){ + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: $size32; + .vis-inner{ + font-family: 'Helsinki Grotesk Medium', sans-serif !important; + font-weight: 500; + font-size: $size16; + } + } + + .vis-group-level-0.expanded:not(.vis-nested-group){ + padding-left: $size13; + .vis-inner{ + padding-left: $size8; + .timeline-buttons-container{ + display: flex; + justify-content: center; + align-items: center; + .timeline-add-button{ + z-index: 1000; + position: absolute; + right: 11px; + top: 5px; + padding: 0; + width: 24px; + height: 24px; + background:url('../../assets/icons/plus-blue.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size24; + border: none; + cursor: pointer; + &:hover { + background-color: #CCCCFF; + } + &:active, &.menu-open { + background-color: #CCCCFF; + outline: 2px solid #0072c6; + outline-offset: 2px; + } + &:focus, &:focus-visible { + outline: 2px solid #0072c6; + outline-offset: 2px; + } + } + } + } + } + + .vis-group-level-0.collapsed:not(.vis-nested-group){ + padding-left: $size13; + .vis-inner{ + padding-left: $size8; + .timeline-buttons-container{ + display: flex; + justify-content: center; + align-items: center; + .timeline-add-button{ + display: none; + } + } + } + } + + .vis-nesting-group{ + display: flex; + justify-content: flex-start; + align-items: center; + margin-right: -1px; + height: 35px !important; + .vis-group-level-0:not(.vis-nested-group){ + display: block; + padding-left: $size16; + } + } + + .vis-label.vis-nesting-group.expanded:before { + content: ""; + width: $size18; + height: 100%; + background: url('../../assets/icons/angle-up.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size18; + } + + .vis-label.vis-nesting-group.collapsed:before{ + content: ""; + width: $size18; + height: 100%; + background: url('../../assets/icons/angle-down.svg') no-repeat; + background-position-x: center; + background-position-y: center; + background-size: $size18; + } + + .vis-text.vis-major { + text-align: center; + border-color: #999999; + } + + .vis-text.vis-minor { + color: $black1; + &.vis-current-month, &.vis-even, &.vis-odd { + align-content: center; + text-align: center; + border-color: $color-black-20; + border-bottom: none; + } + } + + .vis-major { + border-left: $size1 solid #999999 !important; + &::first-letter{ + text-transform: capitalize; + } + & > div { + background-color: #e6e6e6; + font-weight: 500; + padding: 6px 3px 3px 9px; + margin: -4.5px -3px -4px -3.5px; + } + } + + .vis-minor{ + height: 57px; + background-color: #ffffff; + border-left: $size1 solid #CCCCCC; + border-top: $size1 solid $color-black-20; + border-bottom: $size1 solid $color-black-20; + font-size: $size14; + font-weight: 500; + &::first-line { + font-weight: bold; + } + } + + .vis-time-axis .vis-grid.vis-saturday, + .vis-time-axis .vis-grid.vis-sunday { + background: #F2F2F2; + } + + .vis-saturday,.vis-sunday{ + background: #F2F2F2; + } + + .color-tram{ + background-color: $color-copper !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-summer{ + background-color: $color-summer !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-metro{ + background-color: $color_metro !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-bus{ + background-color: $color_bus !important; + color:white; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-gold{ + background-color: $color_gold !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-fog{ + background-color: $color_fog !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-green{ + background-color: $color_phase_principles !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + .color-suomenlinna{ + background-color: $color_phase_draft !important; + height: $size8; + border-radius: 20px; + z-index: 100; + } + + .vis-foreground .vis-group { + position: relative; + box-sizing: border-box; + border-bottom: none; + height: 35px !important; + } + + /*TODO: v1.2 Disabled for now because we don't want to allow dragging the timeline at this point set to auto*/ + .vis-drag-left{ + pointer-events:none; + } + .vis-drag-right{ + pointer-events:none; + } + .vis-item{ + pointer-events: none; + } + + .inner{ + pointer-events: none; + } + .inner-end{ + pointer-events: none; + } + /*1.2v end*/ +} + +.dimmer{ + padding: $size10 0 0 0 !important; + background-color: rgba(255, 255, 255, 0) !important; +} + +.vis.years{ + .vis-minor, + .vis-major > div { + font-weight: 700; + } + .vis-minor { + border-color: $color-black-20 !important; + } + .vis-minor::first-letter { + text-transform: capitalize; + } + .vis-minor.vis-january { + border-left: 0.063rem solid #999999 !important; + } + .normal-weekend { + background-color: $color-white !important; + } + .negative { + border: none !important; + background-color: transparent !important; + } + .holiday { + top: 0; + background-color: $color-summer-light !important; + z-index: 100; + } +} + +.vis.months { + .vis-time-axis .vis-grid.vis-saturday, + .vis-time-axis .vis-grid.vis-sunday, + .vis-saturday, + .vis-sunday, + .negative { + top: 0; + background: $color-black-5 !important; + border-right: 0.5px solid #ccc !important; + border-left: 0.5px solid #ccc !important; + } + .holiday { + top: 0; + background-color: $color-summer-light !important; + z-index: 100; + border-right: 0.5px solid #ccc !important; + border-left: 0.5px solid #ccc !important; + } + // Temporary rule until we figure out how we can get .negative and .holiday classes to work in .vis-time-axis + .vis-time-axis .vis-saturday, + .vis-time-axis .vis-sunday { + background-color: #ffffff !important; + } +} + +.vis-tooltip{ + visibility: visible !important; + background-color: #ffffff !important; + box-shadow: 0px 2px 8px 0px #00000099 !important; + width: 300px !important; + padding: 8px !important; + pointer-events: auto !important; + white-space: normal !important; + word-wrap: break-word !important; + z-index: 2000 !important; + border: none !important; +} diff --git a/src/components/ProjectTimeline/VisTimelineGroup.js b/src/components/ProjectTimeline/VisTimelineGroup.js new file mode 100644 index 000000000..3b84b28e8 --- /dev/null +++ b/src/components/ProjectTimeline/VisTimelineGroup.js @@ -0,0 +1,986 @@ +import React, {useRef, useEffect, useState, forwardRef, useImperativeHandle } from 'react'; +import { change } from 'redux-form' +import { useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next' +import { EDIT_PROJECT_TIMETABLE_FORM } from '../../constants' +import Moment from 'moment' +import 'moment/locale/fi'; +import {extendMoment} from 'moment-range' +import { LoadingSpinner } from 'hds-react' +import * as vis from 'vis-timeline' +import 'vis-timeline/dist/vis-timeline-graph2d.min.css' +import TimelineModal from './TimelineModal' +import VisTimelineMenu from './VisTimelineMenu' +import AddGroupModal from './AddGroupModal'; +import ConfirmModal from '../common/ConfirmModal' +import PropTypes from 'prop-types'; +import { getVisibilityBoolName } from '../../utils/projectVisibilityUtils'; +import './VisTimeline.css' +Moment().locale('fi'); + +const VisTimelineGroup = forwardRef(({ groups, items, deadlines, visValues, deadlineSections, formSubmitErrors, projectPhaseIndex, archived, allowedToEdit, isAdmin, disabledDates, lomapaivat, dateTypes, trackExpandedGroups, sectionAttributes}, ref) => { + const dispatch = useDispatch(); + const moment = extendMoment(Moment); + + const { t } = useTranslation() + + const timelineRef = useRef(null); + const timelineInstanceRef = useRef(null); + const visValuesRef = useRef(visValues); + + const [toggleTimelineModal, setToggleTimelineModal] = useState({open: false, highlight: false, deadlinegroup: false}); + const [timelineData, setTimelineData] = useState({group: false, content: false}); + const [timeline, setTimeline] = useState(false); + const [addDialogStyle, setAddDialogStyle] = useState({ left: 0, top: 0 }); + const [addDialogData, setAddDialogData] = useState({group:false,deadlineSections:false,showPresence:false,showBoard:false,nextEsillaolo:false,nextLautakunta:false,esillaoloReason:"",lautakuntaReason:"",hidePresence:false,hideBoard:false}); + const [toggleOpenAddDialog, setToggleOpenAddDialog] = useState(false) + const [currentFormat, setCurrentFormat] = useState("showYears"); + const [openConfirmModal, setOpenConfirmModal] = useState(false); + const [dataToRemove, setDataToRemove] = useState({}); + const [timelineAddButton, setTimelineAddButton] = useState(); + //const [lock, setLock] = useState({group:false,id:false,locked:false,abbreviation:false}); + + useImperativeHandle(ref, () => ({ + getTimelineInstance: () => timelineInstanceRef.current, + })); + + + const groupDragged = (id) => { + console.log('onChange:', id) + } + + const preventDefaultAndStopPropagation = (event) => { + event.preventDefault(); + event.stopPropagation(); + } + + const updateGroupShowNested = (groups, groupId, showNested) => { + if (groupId) { + let group = groups.get(groupId); + if (group) { + group.showNested = showNested; + groups.update(group); + } + } + } + + const timelineGroupClick = (properties, groups) => { + if (properties.group) { + let clickedElement = properties.event.target; + + preventDefaultAndStopPropagation(properties.event); + + if(clickedElement.classList.contains('timeline-add-button')){ + updateGroupShowNested(groups, properties.group, true); + } else { + let groupId = properties.group; + if (groupId) { + let group = groups.get(groupId); + if (group) { + updateGroupShowNested(groups, properties.group, !group.showNested); + } + } + } + } + } + + const trackExpanded = (event) => { + trackExpandedGroups(event) + } + + const checkConfirmedGroups = (esillaoloConfirmed, lautakuntaConfirmed, attributeKeys, visValRef, phase, canAddEsillaolo, nextEsillaoloClean, canAddLautakunta, nextLautakuntaClean, data) => { + // Check if more Esillaolo groups can be added + let esillaoloReason = !esillaoloConfirmed ? "noconfirmation" : ""; + let lautakuntaReason = !lautakuntaConfirmed ? "noconfirmation" : ""; + const deadlineEsillaolokertaKeys = data.maxEsillaolo + const esillaoloRegex = new RegExp(`(jarjestetaan_${phase}_esillaolo_\\d+$|kaava${phase}_uudelleen_nahtaville_\\d+$)`); + const attributeEsillaoloKeys = Object.keys(visValRef).filter(key => esillaoloRegex.test(key)); + let largestIndex = 0; + //find largest index + attributeEsillaoloKeys.forEach(key => { + const match = /_(\d+)$/.exec(key); + if (match) { + const number = parseInt(match[1], 10); + if (number > largestIndex && visValRef[key]) { + largestIndex = number; + } + } + }); + let esillaoloCount = largestIndex + //If no index found add one + if(esillaoloCount === 0){ + esillaoloCount += 1 + } + esillaoloCount = esillaoloCount + 1; + if(esillaoloCount - 1 === deadlineEsillaolokertaKeys){ + esillaoloReason = "max" + } + + let nextEsillaoloStr + if (esillaoloConfirmed) { + canAddEsillaolo = esillaoloCount <= deadlineEsillaolokertaKeys; + nextEsillaoloStr = canAddEsillaolo ? `jarjestetaan_${phase}_esillaolo_${esillaoloCount}$` : false; + nextEsillaoloClean = nextEsillaoloStr ? nextEsillaoloStr.replace(/[/$]/g, '') : nextEsillaoloStr; + } + else if((phase === "luonnos" && visValRef[`jarjestetaan_${phase}_esillaolo_1`] === false) || + (phase === "periaatteet" && visValRef[`jarjestetaan_${phase}_esillaolo_1`] === false) ){ + canAddEsillaolo = true + nextEsillaoloStr = `jarjestetaan_${phase}_esillaolo_1`; + nextEsillaoloClean = nextEsillaoloStr ? nextEsillaoloStr.replace(/[/$]/g, '') : nextEsillaoloStr; + esillaoloReason = "" + } + // Check if more Lautakunta groups can be added + const deadlineLautakuntakertaKeys = data.maxLautakunta + const lautakuntaanRegex = phase === "luonnos" || phase === "ehdotus" ? new RegExp(`kaava${phase}_lautakuntaan_\\d+$`): new RegExp(`${phase}_lautakuntaan_\\d+$`); + const lautakuntaanRegex2 = new RegExp(`${phase}_lautakunnassa_\\d+$`); + const attributeLautakuntaanKeys = Object.keys(visValRef).filter(key => lautakuntaanRegex.test(key) || lautakuntaanRegex2.test(key)); + let largestIndexLautakunta = 0; + //find largest index + attributeLautakuntaanKeys.forEach(key => { + const match = /_(\d+)$/.exec(key); + if (match) { + const number = parseInt(match[1], 10); + if (number > largestIndexLautakunta && visValRef[key]) { + largestIndexLautakunta = number; + } + } + }); + + let lautakuntaCount = largestIndexLautakunta + if(lautakuntaCount === 0){ + lautakuntaCount += 1 + } + lautakuntaCount = lautakuntaCount + 1; + if(lautakuntaCount - 1 === deadlineLautakuntakertaKeys){ + lautakuntaReason = "max" + } + + let nextLautakuntaStr + if (lautakuntaConfirmed) { + canAddLautakunta = lautakuntaCount <= deadlineLautakuntakertaKeys; + const lautakuntaText = phase === "luonnos" || phase === "ehdotus" ? `kaava${phase}_lautakuntaan_${lautakuntaCount}$` : `${phase}_lautakuntaan_${lautakuntaCount}$` + nextLautakuntaStr = canAddLautakunta ? lautakuntaText : false; + nextLautakuntaClean = nextLautakuntaStr ? nextLautakuntaStr.replace(/[/$]/g, '') : nextLautakuntaStr; + } + else if((phase === "luonnos" && visValRef[`kaava${phase}_lautakuntaan_1`] === false) || + (phase === "periaatteet" && visValRef[`${phase}_lautakuntaan_1`] === false) ){ + canAddLautakunta = true + nextLautakuntaStr = phase === "luonnos" ? `kaava${phase}_lautakuntaan_1` : `${phase}_lautakuntaan_1`; + nextLautakuntaClean = nextLautakuntaStr ? nextLautakuntaStr.replace(/[/$]/g, '') : nextLautakuntaStr; + lautakuntaReason = "" + } + + return [canAddEsillaolo, nextEsillaoloClean, canAddLautakunta, nextLautakuntaClean, esillaoloReason, lautakuntaReason]; + } + + const hideSelection = (phase,data) => { + //hide add options for certain phases + if(phase === "Tarkistettu ehdotus"){ + return [true,false] + } + else if(phase === "Ehdotus" && (data?.kaavaprosessin_kokoluokka === "XS" || data?.kaavaprosessin_kokoluokka === "S" || data?.kaavaprosessin_kokoluokka === "M")){ + return [false,true] + } + else if(phase === "OAS"){ + return [false,true] + } + + return [false,false] + } + + const canGroupBeAdded = (visValRef, data, deadlineSections) => { + // Find out how many groups in the clicked phase have been added to the timeline + const matchingGroups = groups.get().filter(group => data.nestedGroups.includes(group.id)); + const esillaoloCount = matchingGroups.filter(group => group.content.includes('Esilläolo') || group.content.includes('Nahtavillaolo')).length > 1 ? '_' + matchingGroups.filter(group => group.content.includes('Esilläolo') || group.content.includes('Nahtavillaolo')).length : ''; + const lautakuntaCount = matchingGroups.filter(group => group.content.includes('Lautakunta')).length > 1 ? '_' + matchingGroups.filter(group => group.content.includes('Lautakunta')).length : ''; + const phase = data.content.toLowerCase().replace(/\s+/g, '_'); + // Check if existing groups have been confirmed + let esillaoloConfirmed = Object.hasOwn(visValRef, `vahvista_${phase}_esillaolo_alkaa${esillaoloCount}`) && visValRef[`vahvista_${phase}_esillaolo_alkaa${esillaoloCount}`] === true + || Object.hasOwn(visValRef, `vahvista_${phase}_esillaolo${esillaoloCount}`) && visValRef[`vahvista_${phase}_esillaolo${esillaoloCount}`] === true; + let lautakuntaConfirmed = Object.hasOwn(visValRef, `vahvista_${phase}_lautakunnassa${lautakuntaCount}`) && visValRef[`vahvista_${phase}_lautakunnassa${lautakuntaCount}`] === true; + //this could be needed when confirmation is no longer needed + if(phase === "luonnos"){ + lautakuntaConfirmed = Object.hasOwn(visValRef, `vahvista_kaavaluonnos_lautakunnassa${lautakuntaCount}`) && visValRef[`vahvista_kaavaluonnos_lautakunnassa${lautakuntaCount}`] === true; + } +/* if(phase === "periaatteet" && !(phase + "_lautakuntaan_1" in visValRef) || phase === "periaatteet" && visValRef["periaatteet_lautakuntaan_1"] || phase === "luonnos" && !(phase + "_lautakuntaan_1") || phase === "luonnos" && visValRef["kaavaluonnos_lautakuntaan_1"] === false){ + lautakuntaConfirmed = true + } */ + if(phase === "luonnos" && !("jarjestetaan_" + phase + "_esillaolo_1" in visValRef) || phase === "periaatteet" && !("jarjestetaan_"+phase+"_esillaolo_1" in visValRef)){ + esillaoloConfirmed = true + } + if(phase === "ehdotus" && "vahvista_kaavaehdotus_lautakunnassa" in visValRef){ + lautakuntaConfirmed = true + } + + // Initialize returned values + let canAddEsillaolo = false; + let nextEsillaoloClean = false; + let canAddLautakunta = false; + let nextLautakuntaClean = false; + let esillaoloReason = ""; + let lautakuntaReason = ""; + + // Get attribute keys for comparison from deadlineSections + const matchingKeys = Object.keys(deadlineSections).filter(key => data.content === deadlineSections[key].title); + let attributeKeys = []; + if (matchingKeys.length > 0 && deadlineSections[matchingKeys[0]].sections[0].attributes) { + attributeKeys = Object.keys(deadlineSections[matchingKeys[0]].sections[0].attributes); + } + + const results = checkConfirmedGroups(esillaoloConfirmed, lautakuntaConfirmed, attributeKeys, visValRef, phase, canAddEsillaolo, nextEsillaoloClean, canAddLautakunta, nextLautakuntaClean,data); + [canAddEsillaolo, nextEsillaoloClean, canAddLautakunta, nextLautakuntaClean, esillaoloReason, lautakuntaReason] = results; + let phaseWithoutSpace = phase.toLowerCase().replace(/\s+/g, '-'); + + if(typeof visValRef["lautakunta_paatti_"+phaseWithoutSpace] === "undefined" || visValRef["lautakunta_paatti_"+phaseWithoutSpace] === "hyvaksytty" || visValRef["lautakunta_paatti_"+phaseWithoutSpace] === "palautettu_uudelleen_valmisteltavaksi"){ + if( (phase === "luonnos" && visValRef[`kaava${phase}_lautakuntaan_1`] === false) || + (phase === "periaatteet" && visValRef[`${phase}_lautakuntaan_1`] === false) ) { + canAddLautakunta = true + } + else{ + canAddLautakunta = false + } + } + /* TODO if(visValRef["kaavaluonnos_lautakuntaan_1"] === false){ + //adds second one althou when false needs to just show first one if deleted once + canAddLautakunta = true + } */ + + return [canAddEsillaolo, nextEsillaoloClean, canAddLautakunta, nextLautakuntaClean, esillaoloReason, lautakuntaReason]; + }; + + const openAddDialog = (visValRef,data,event) => { + const [addEsillaolo,nextEsillaolo,addLautakunta,nextLautakunta,esillaoloReason,lautakuntaReason] = canGroupBeAdded(visValRef,data,deadlineSections) + const rect = event.target.getBoundingClientRect(); + + if (event.target.classList.contains('timeline-add-button')) { + setTimelineAddButton(event.target); + } + + setAddDialogStyle({ + left: `${rect.left - 12}px`, + top: `${rect.bottom - 10}px` + }) + + const [hidePresence,hideBoard] = hideSelection(data.content,visValRef) + setAddDialogData({group:data,deadlineSections:deadlineSections,showPresence:addEsillaolo,showBoard:addLautakunta, + nextEsillaolo:nextEsillaolo,nextLautakunta:nextLautakunta,esillaoloReason:esillaoloReason,lautakuntaReason:lautakuntaReason, + hidePresence:hidePresence,hideBoard:hideBoard}) + setToggleOpenAddDialog(prevState => !prevState) + } + + const openRemoveDialog = (data) => { + setOpenConfirmModal(!openConfirmModal) + setDataToRemove(data) + } + + const handleCancelRemove = () => { + setOpenConfirmModal(!openConfirmModal) + } + + const handleRemoveGroup = () => { + const visiblityBool = getVisibilityBoolName(dataToRemove.deadlinegroup) + if (visiblityBool) { + dispatch(change(EDIT_PROJECT_TIMETABLE_FORM, visiblityBool, false)); + } + setOpenConfirmModal(!openConfirmModal) + } + + const closeAddDialog = () => { + setToggleOpenAddDialog(prevState => !prevState) + }; + + + const lockLine = (data) => { + console.log(data) + //setLock({group:data.nestedInGroup,id:data.id,abbreviation:data.abbreviation,locked:!data.locked}) + } + + + const openDialog = (data,container) => { + //remove already highlighted + timelineRef?.current?.querySelectorAll('.highlight-selected').forEach(el => { + el.classList.remove('highlight-selected'); + if (el.parentElement.parentElement) { + el.parentElement.parentElement.classList.remove('highlight-selected'); + } + }); + //highlight the latest group + if (container) { + container.classList.toggle("highlight-selected"); + if (container.parentElement.parentElement) { + container.parentElement.parentElement.classList.toggle("highlight-selected"); + } + } + const modifiedDeadlineGroup = data?.deadlinegroup?.includes(';') ? data.deadlinegroup.split(';')[0] : data.deadlinegroup; + setToggleTimelineModal({open:!toggleTimelineModal.open, highlight:container, deadlinegroup:modifiedDeadlineGroup}) + //Set data from items + setTimelineData({group:data.nestedInGroup, content:data.content}) + } + + const changeItemRange = (subtract, item, i) => { + const timeline = timelineRef?.current?.getTimelineInstance(); + if(timeline){ + let timeData = i + if(!subtract){ + let originalDiff = moment.duration(moment(timeData.end).diff(moment(timeData.start))) + let originalTimeFrame = originalDiff.asDays() + timeData.start = item.end + timeData.end = moment(timeData.start).add(originalTimeFrame, 'days').toDate() + } + else{ + timeData.end = item.start + } + timeline.itemSet.items[i.id].setData(timeData) + timeline.itemSet.items[i.id].repositionX() + } + } + //For vis timeline dragging 1.2v + /*const onRangeChanged = ({ start, end }) => { + console.log(start, end) + const Min = 1000 * 60 * 60 * 24; // one day in milliseconds + const Max = 31556952000; // 1000 * 60 * 60 * 24 * 365.25 one year in milliseconds + let a0 = 10; + let a100 = moment.duration(moment(Max).diff(moment(Min))).asMilliseconds(); + let distance = (a100 - a0)/ 100; + let startTime = moment(start); + let endTime = moment(end); + const duration = moment.duration(endTime.diff(startTime)); + const mins = duration.asMilliseconds(); + // Arithmatic progression variables + if (mins !== 0) { + const x = (mins - a0) / distance; // Arithmatic progression formula + console.log(x) + if(x > 50){ + console.log("smaller then 50") + document.querySelectorAll('.inner, .inner-end').forEach(el => el.classList.add('hiddenTimes')); + } + else if(x < 50 && document.querySelectorAll('.hiddenTimes')){ + console.log("bigger then 50") + document.querySelectorAll('.inner, .inner-end').forEach(el => el.classList.remove('hiddenTimes')); + } + } else { + if(!document.querySelectorAll('.hiddenTimes')){ + console.log("100") + document.querySelectorAll('.inner, .inner-end').forEach(el => el.classList.add('hiddenTimes')); + } + } + + } */ + + /** + * Move the timeline a given percentage to left or right + * @param {Number} percentage For example 0.1 (left) or -0.1 (right) + */ + const move = (percentage) => { + let range = timeline.getWindow(); + let interval = range.end - range.start; + + timeline.setWindow({ + start: range.start.valueOf() - interval * percentage, + end: range.end.valueOf() - interval * percentage, + }); + } + + const showMonths = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOfMonth = new Date(currentYear, now.getMonth(), 1); + let endOfMonth = new Date(currentYear, now.getMonth() + 1, 0); + timelineRef.current.classList.remove("years") + timelineRef.current.classList.add("months") + timeline.setOptions({timeAxis: {scale: 'weekday'}}); + timeline.setWindow(startOfMonth, endOfMonth); + setCurrentFormat("showMonths"); + } + + const showYears = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOfYear = new Date(currentYear, 0, 1); + let endOfYear = new Date(currentYear, 11, 31); + timelineRef.current.classList.remove("months") + timelineRef.current.classList.add("years") + timeline.setOptions({timeAxis: {scale: 'month'}}); + timeline.setWindow(startOfYear, endOfYear); + setCurrentFormat("showYears"); + } + + const show2Yers = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOf2Years = new Date(currentYear, now.getMonth(), 1); + let endOf2Years = new Date(currentYear + 2, now.getMonth(), 0); + timeline.setOptions({timeAxis: {scale: 'month'}}); + timeline.setWindow(startOf2Years, endOf2Years); + } + + const show5Yers = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOf5Years = new Date(currentYear, now.getMonth(), 1); + let endOf5Years = new Date(currentYear + 5, now.getMonth(), 0); + timeline.setOptions({timeAxis: {scale: 'month'}}); + timeline.setWindow(startOf5Years, endOf5Years); + } + + const show3Months = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOf3Months = new Date(currentYear, now.getMonth(), 1); + let endOf3Months = new Date(currentYear, now.getMonth() + 3, 0); + timeline.setOptions({timeAxis: {scale: 'weekday'}}); + timeline.setWindow(startOf3Months, endOf3Months); + } + + const show6Months = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOf6Months = new Date(currentYear, now.getMonth(), 1); + let endOf6Months = new Date(currentYear, now.getMonth() + 6, 0); + timeline.setOptions({timeAxis: {scale: 'weekday'}}); + timeline.setWindow(startOf6Months, endOf6Months); + } + + const showWeeks = () => { + let now = new Date(); + let currentYear = now.getFullYear(); + let startOfWeek = new Date(currentYear, now.getMonth(), now.getDate() - now.getDay()); + let endOfWeek = new Date(currentYear, now.getMonth(), now.getDate() - now.getDay() + 6); + timeline.setWindow(startOfWeek, endOfWeek); + } + + const showDays = () => { + let ONE_DAY_IN_MS = 1000 * 60 * 60 * 24; + let now = new Date(); + let nowInMs = now.getTime(); + let oneDayFromNow = nowInMs + ONE_DAY_IN_MS; + timeline.setWindow(nowInMs, oneDayFromNow); + } + // attach events to the navigation buttons + const zoomIn = () => { + timeline.zoomIn(1); + } + + const zoomOut = () => { + timeline.zoomOut(1); + } + + const moveLeft = () => { + move(0.25); + } + + const moveRight = () => { + move(-0.25); + } + + const goToToday = () => { + const currentDate = new Date(); + timeline.moveTo(currentDate, {animation: true}); + } + + const toggleRollingMode = () => { + timeline.toggleRollingMode(); + } + + const adjustWeekend = (date) => { + if (date.getDay() === 0) { + date.setTime(date.getTime() + 86400000); // Move from Sunday to Monday + } else if (date.getDay() === 6) { + date.setTime(date.getTime() - 86400000); // Move from Saturday to Friday + } + } + + useEffect(() => { + + const options = { + locales: { + fi: { + current: "Nykyinen", + time: "Aika", + } + }, + locale: 'fi', + stack: false, + selectable: false, + multiselect: false, + sequentialSelection: false, + moveable:true, // Dragging is disabled from VisTimeline.scss allow in v1.2 + zoomable:false, + horizontalScroll:true, + groupHeightMode:"fixed", + start: new Date(), + end: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 365.25), + zoomMin: 1000 * 60 * 60 * 24, // one day in milliseconds + zoomMax: 157784760000, // 1000 * 60 * 60 * 24 * 5 * 365.25 5 year in milliseconds + margin: { + item: 20 + }, + align: 'center', + editable: { + add: false, // add new items by double tapping + updateTime: true, // drag items horizontally + updateGroup: false, // drag items from one group to another + remove: false, // delete an item by tapping the delete button top right + overrideItems: false // allow these options to override item.editable + }, + itemsAlwaysDraggable: { // Dragging is disabled from VisTimeline.scss allow in v1.2 + item:true, + range:true + }, + orientation:{ + axis: "top", + }, + format: { + minorLabels: { + millisecond:'SSS', + second: 's', + minute: 'HH:mm', + hour: 'HH:mm', + weekday: 'D
ddd', + day: 'D', + week: 'w', + month: 'MMM', + year: 'YYYY' + }, + majorLabels: { + millisecond:'HH:mm:ss', + second: 'D MMMM HH:mm', + minute: 'ddd D MMMM', + hour: 'ddd D MMMM', + weekday: 'MMMM YYYY', + day: 'MMMM YYYY', + week: 'MMMM YYYY', + month: 'YYYY', + year: '' + } + }, + // always snap to full hours, independent of the scale + snap: function (date) { + let hour = 60 * 60 * 1000; + return Math.round(date / hour) * hour; + }, + onMove(item, callback) { + let preventMove = false; + + const adjustIfWeekend = (date) => { + if (!(date.getDay() % 6)) { + adjustWeekend(date); + return true; + } + return false; + } + + if (!adjustIfWeekend(item.start) && !adjustIfWeekend(item.end)) { + const movingTimetableItem = moment.range(item.start, item.end); + if (item.phase) { + items.forEach(i => { + if (i.phase && i.id !== item.id) { + const statickTimetables = moment.range(i.start, i.end); + if (movingTimetableItem.overlaps(statickTimetables)) { + preventMove = false; + changeItemRange(item.start > i.start, item, i); + } + } + }); + } else { + items.forEach(i => { + if (i.id !== item.id) { + if (item.phaseID === i.phaseID && !preventMove && !i.locked) { + preventMove = false; + } else { + const statickTimetables = moment.range(i.start, i.end); + if (movingTimetableItem.overlaps(statickTimetables)) { + preventMove = true; + } + } + } + }); + } + } + + if (item.content != null && !preventMove) { + callback(item); // send back adjusted item + } else { + callback(null); // cancel updating the item + } + }, + groupTemplate: function (group) { + if (group === null) { + return; + } + let container = document.createElement("div"); + container.classList.add("timeline-buttons-container"); + container.setAttribute("tabindex", "0"); + let words = group.deadlinegroup?.split("_") || []; + let words2 = group.content?.split("-") || []; + let normalizedString = words2[0] + .replace(/[äå]/gi, 'a') // Replace ä and å with a + .replace(/ö/gi, 'o') // Replace ö with o + .toLowerCase(); // Convert to lowercase + + let wordsToCheck = ["vahvista_",words[0], normalizedString, words[2] === "1" ? "" : words[2]]; + const keys = Object.entries(visValuesRef?.current); + + const deletableGroup = keys.some(([key, value]) =>{ + const allWordsInKey = wordsToCheck.every(word => key.includes(word)) + return allWordsInKey && value; + }); + + //Don't show buttons in these groups + const stringsToCheck = ["Käynnistys", "Hyväksyminen", "Voimaantulo", "Vaiheen kesto"]; + const contentIncludesString = stringsToCheck.some(str => group?.content.includes(str)); + + // Hover effect + container.addEventListener("mouseenter", function() { + // Action to perform on hover enter, e.g., change background color + container.classList.add("show-buttons"); + }); + container.addEventListener("mouseleave", function() { + // Action to perform on hover leave + container.classList.remove("show-buttons"); + }); + + if(group?.nestedGroups!== undefined && allowedToEdit && !contentIncludesString){ + let label = document.createElement("span"); + label.innerHTML = group.content + " "; + container.insertAdjacentElement("afterBegin", label); + let add = document.createElement("button"); + add.classList.add("timeline-add-button"); + add.style.fontSize = "small"; + + add.addEventListener("click", function (event) { + openAddDialog(visValuesRef.current,group,event); + }); + container.insertAdjacentElement("beforeEnd", add); + + return container; + } + else if(group?.nestedInGroup){ + // Get, format and add labels + let label = document.createElement("span"); + let content = group.content; + label.classList.add("timeline-button-label"); + + const formattedContent = formatContent(content, false); + label.innerHTML = formattedContent + " "; + + container.insertAdjacentElement("afterBegin", label); + + let edit = document.createElement("button"); + edit.classList.add("timeline-edit-button"); + edit.style.fontSize = "small"; + + edit.addEventListener("click", function () { + openDialog(group,container); + }); + container.insertAdjacentElement("beforeEnd", edit); + + if(allowedToEdit && !contentIncludesString){ + let labelRemove = document.createElement("span"); + container.insertAdjacentElement("afterBegin", labelRemove); + let remove = document.createElement("button"); + remove.classList.add("timeline-remove-button"); + + // Tooltip for disabled remove button + let removeTextDiv = ""; + if (label.innerHTML.includes("Esilläolo")) { + removeTextDiv = `
${t('deadlines.delete-first-esillaolo')}
`; + } else if (label.innerHTML.includes("Lautakunta")) { + removeTextDiv = `
${t('deadlines.delete-first-lautakunta')}
`; + } else if (label.innerHTML.includes("Nähtävilläolo")) { + removeTextDiv = `
${t('deadlines.delete-first-nahtavillaolo')}
`; + } + + //only not confirmed groups can be deleted + if(deletableGroup || group.undeletable || visValuesRef?.current[`vahvista_${words[0]}_${normalizedString}_alkaa_${words[2]}`]){ + // add button-disabled class to the remove button if the group is not deletable + remove.classList.add("button-disabled") + } + remove.style.fontSize = "small"; + + remove.addEventListener("click", function () { + // disabled button can't be clicked + if (!remove.classList.contains("button-disabled")) { + openRemoveDialog(group); + } + }); + + container.insertAdjacentElement("beforeEnd", remove); + + // Add tooltip for disabled remove buttons + if (deletableGroup || group.undeletable) { + container.insertAdjacentHTML("beforeEnd", removeTextDiv); + } + + let lock = document.createElement("button"); + lock.classList.add("timeline-lock-button"); + lock.style.fontSize = "small"; + lock.addEventListener("click", function () { + lock.classList.toggle("lock"); + /*const locked = lock.classList.contains("lock") ? "inner locked" : "inner"; + let visibleItems = timelineInstanceRef?.current?.getVisibleItems() + if(visibleItems){ + for (const visibleItem of visibleItems) { + const item = items.get(visibleItem); + if (!item.phase && item.id >= group.id) { + items.update({ id: item.id, className: locked, locked: !item.locked }); + } + } + } */ + lockLine(group); + }); + container.insertAdjacentElement("beforeEnd", lock); + } + return container; + } + else{ + let label = document.createElement("span"); + label.classList.add("timeline-phase-label"); + label.innerHTML = group?.content + " "; + container.insertAdjacentElement("afterBegin", label); + return container; + } + }, + } + + // Create the tooltip element + const tooltipDiv = document.createElement('div'); + tooltipDiv.className = 'vis-tooltip'; + tooltipDiv.style.display = 'none'; + document.body.appendChild(tooltipDiv); + + // Tooltip show and hide functions + const showTooltip = (event, item) => { + const offsetX = 150; + tooltipDiv.style.display = 'block'; + tooltipDiv.style.left = `${event.pageX - offsetX}px`; + tooltipDiv.style.top = `${event.pageY + 20}px`; + tooltipDiv.innerHTML = ` + Vaihe: ${item?.phaseName}
+ ${item?.groupInfo ? "Nimi: " + item?.groupInfo + "
" : ""} + ${item?.start ? "Päivämäärä: " + new Date(item?.start).toLocaleDateString() : ""} + ${item?.start && item?.end && !item?.className.includes('board') ? " - " + new Date(item?.end).toLocaleDateString() : ""} + `; + }; + + const hideTooltip = () => { + tooltipDiv.style.display = 'none'; + }; + + const handleMouseMove = (event) => { + const mouseX = event.clientX; + const mouseY = event.clientY; + + // Check if mouseX is less than 310 to avoid showing tooltip over the vis-left + if (mouseX < 310 || mouseY < 250) { + hideTooltip(); + return; + } + + let hoveredItem = null; + + // Access items in the timeline and check if mouse is over any item with certain class + if (timelineInstanceRef.current && timelineInstanceRef.current.itemSet) { + const items = Object.values(timelineInstanceRef.current.itemSet.items); + let highestZIndex = -1; + let topmostItem = null; + items.forEach((item) => { + const itemDom = item?.dom?.box || item?.dom?.point || item?.dom?.dot; + if (itemDom && (itemDom.classList.contains('vis-editable'))) { + const itemBounds = itemDom.getBoundingClientRect(); + + // Check if mouse is within the item's bounding box + if ( + mouseX >= itemBounds.left && + mouseX <= itemBounds.right && + mouseY >= itemBounds.top && + mouseY <= itemBounds.bottom + ) { + const zIndex = parseInt(window.getComputedStyle(itemDom).zIndex, 10); + if (zIndex > highestZIndex) { + highestZIndex = zIndex; + topmostItem = item; + } + } + } + }); + if (topmostItem) { + hoveredItem = topmostItem; + } + } + + if (hoveredItem) { + showTooltip(event, hoveredItem.data); + } else { + hideTooltip(); + } + }; + + // Attach the mousemove event to the container, not the items themselves + timelineRef.current.addEventListener('mousemove', handleMouseMove); + + if(items && options && groups){ + const timeline = timelineRef.current && + new vis.Timeline(timelineRef.current, items, options, groups); + timelineInstanceRef.current = timeline + setTimeline(timeline) + // add event listener + timeline.on('groupDragged', groupDragged) + + if (timeline?.itemSet) { + // remove the default internal hammer tap event listener + timeline.itemSet.groupHammer.off('tap'); + // use my own fake internal hammer tap event listener + timeline.itemSet.groupHammer.on('tap', function (event) { + let target = event.target; + if (target.classList.contains('timeline-add-button')) { + //Custom function to add new item + timelineGroupClick(timeline.itemSet.options,groups) + } + else { + trackExpanded(event) + // if not add button, forward the event to the vis event handler + timeline.itemSet._onGroupClick(event); + } + }); + + } + + timeline.focus(0); + //timeline.on('rangechanged', onRangeChanged); + return () => { + // Check if tooltipDiv exists before trying to remove it + if (tooltipDiv) { + tooltipDiv.remove(); // Remove from DOM + } + + if (timelineInstanceRef.current) { + timelineInstanceRef.current.destroy(); + timelineInstanceRef.current.off('itemover', showTooltip); + timelineInstanceRef.current.off('itemout', hideTooltip); + document.body.removeEventListener('mousemove', handleMouseMove); + } + timeline.off('groupDragged', groupDragged) + //timeline.off('rangechanged', onRangeChanged); + } + } + }, []) + + useEffect(() => { + visValuesRef.current = visValues; + setToggleOpenAddDialog(false) + if (timelineRef.current) { + if (timelineInstanceRef.current) { + //Update timeline when values change from side modal + timelineInstanceRef.current.setItems(items); + timelineInstanceRef.current.redraw(); + } + } + }, [visValues]); + + const formatContent = (content, keepNumberOne = false) => { + if (content) { + if (content.includes("-1") && !keepNumberOne) { + content = content.replace("-1", ""); + } else if (content.includes("-")) { + content = content.replace("-", " - "); + } else if (content.includes("Vaiheen kesto")) { + content = "Vaiheen lisätiedot"; + } + + if (content.includes("Nahtavillaolo")) { + content = content.replace("Nahtavillaolo", "Nähtävilläolo"); + } + + return content; + } + }; + + return ( + !deadlines ? + : + <> +
+ +
+ + + + + ) +}); +VisTimelineGroup.displayName = 'VisTimelineGroup'; +VisTimelineGroup.propTypes = { + groups: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.bool + ]), + items: PropTypes.object, + deadlines: PropTypes.array, + visValues: PropTypes.object, + deadlineSections: PropTypes.array, + formSubmitErrors: PropTypes.object, + projectPhaseIndex: PropTypes.number, + archived: PropTypes.bool, + allowedToEdit: PropTypes.bool, + isAdmin: PropTypes.bool, + disabledDates: PropTypes.array, + lomapaivat: PropTypes.array, + dateTypes: PropTypes.object, + trackExpandedGroups: PropTypes.func, + sectionAttributes: PropTypes.array +}; +export default VisTimelineGroup \ No newline at end of file diff --git a/src/components/ProjectTimeline/VisTimelineMenu.js b/src/components/ProjectTimeline/VisTimelineMenu.js new file mode 100644 index 000000000..422c00b9f --- /dev/null +++ b/src/components/ProjectTimeline/VisTimelineMenu.js @@ -0,0 +1,50 @@ +import React, { useState } from 'react' +import {Button,IconAngleLeft,IconAngleRight} from 'hds-react' +import PropTypes from 'prop-types'; + +function VisTimelineMenu({goToToday, moveLeft, moveRight,showYears,showMonths}) { + const [selectedButton, setSelectedButton] = useState(null); + + const handleClick = (buttonName) => { + setSelectedButton(buttonName); + } + return ( +
+
+
+ + + {/* + + + + + */} +
+
+ +
+
+ + + {/* + + + + + */} +
+
+
+ ) +} + +VisTimelineMenu.propTypes = { + goToToday: PropTypes.func, + moveLeft: PropTypes.func, + moveRight: PropTypes.func, + showYears: PropTypes.func, + showMonths: PropTypes.func +}; + +export default VisTimelineMenu \ No newline at end of file diff --git a/src/components/RichTextEditor/index.js b/src/components/RichTextEditor/index.js index 408d09053..0b1f7b9af 100644 --- a/src/components/RichTextEditor/index.js +++ b/src/components/RichTextEditor/index.js @@ -84,7 +84,8 @@ function RichTextEditor(props) { isFloorAreaForm, floorValue, attributeData, - phaseIsClosed + phaseIsClosed, + fieldDisabled } = props const dispatch = useDispatch() @@ -640,7 +641,7 @@ function RichTextEditor(props) {
diff --git a/src/components/common/Common.scss b/src/components/common/Common.scss index 0e1f3cd30..3816a510c 100644 --- a/src/components/common/Common.scss +++ b/src/components/common/Common.scss @@ -300,8 +300,10 @@ footer{ top: $size24; transform: translateX(-50%); max-width: calc(100vw - 48px); + height: 80vh; width: 740px; margin-top: 0; + scrollbar-gutter: stable; > .header { border-bottom: 0; @@ -315,6 +317,9 @@ footer{ top: 32px; } } + &.tiny { + height: 350px; + } .close.icon { position: relative; float: right; @@ -1044,3 +1049,161 @@ header{ box-shadow: 0px 2px 8px rgba(0, 0, 0, .5); z-index: 80; } + +.timetable-confirm-modal.timetable-confirm-modal { + height: auto; + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 0; + margin: auto; + max-width: 558px; + max-height: 245px; + border-top:4px solid $color-bus; + + .header{ + display: flex; + align-items: center; + justify-content: flex-start; + font-family: 'Helsinki Grotesk Medium', sans-serif; + font-size: $size18 !important; + font-weight: 700 !important; + line-height: $size18 !important; + letter-spacing: 0.004em; + text-align: left; + color: $black1; + padding-top: 0; + .header-icon{ + width: $size24; + height: $size24; + } + } + + .header, .content, .actions { + padding-left: $size32 !important; + margin-left: $size16 !important; + margin-bottom: $size24 !important; + margin-top: $size24 !important; + } + + .actions { + display: flex; + justify-content: flex-start; + + .button-primary,.button-danger,.button-secondary { + margin-right: $size16; + } + } +} + +.timetable-danger-modal.timetable-danger-modal { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 0; + margin: auto; + max-width: 558px; + min-height: 200px; + height: fit-content; + max-height: 250px; + border-top:8px solid $color-error; + + .header{ + padding-top: $size34 !important; + padding-left: $size34 !important; + padding-bottom: $size16 !important; + border-bottom: none; + display: flex; + align-items: center; + justify-content: flex-start; + font-family: 'Helsinki Grotesk Medium', sans-serif; + font-size: $size18 !important; + font-weight: 700 !important; + line-height: $size18 !important; + letter-spacing: 0.004em; + text-align: left; + color: $black1; + .header-text { + margin-left: 8px; + } + .header-icon{ + width: $size24; + height: $size24; + } + } + + .content { + font-weight: 400; + font-size: $size16; + line-height: $size24; + padding-left: $size34 !important; + margin-left: 0 !important; + } + + .actions { + display: flex; + justify-content: flex-start; + margin: 0 !important; + padding-left: $size34 !important; + padding-top: $size16 !important; + padding-bottom: $size24 !important; + + .button-primary,.button-danger,.button-secondary { + margin-right: $size16; + } + .button-secondary{ + border: 2px solid $color-error; + color: $color-error + } + } +} + +// Style for the custom toastr with default error animation +.redux-toastr{ + .large-scrollable-toastr { + + .toast { + width: 500px; // Make the toastr wider + height: auto; + padding: 20px; + } + + .toast-message { + max-height: 300px; // Limit the height to make it scrollable + overflow-y: auto; // Enable vertical scrolling + font-size: 16px; // Make the font size larger for readability + } + + .toast-title { + font-size: 18px !important; // Increase the size of the title + } + } + + .top-center { + .rrt-error, .rrt-warning, .rrt-info, .rrt-success { + width: 500px !important; + } + } + + .rrt-middle-container { + width: 75% !important; // Make the middle container wider + top: 14px; + } + + // Style for default container classes (left, middle, right) + .rrt-left-container, .rrt-middle-container, .rrt-right-container { + padding: 10px !important; // Add padding for spacing + .rrt-text{ + word-wrap: break-word !important; + overflow: auto !important; + max-height: 200px !important; + } + .toast { + width: 500px; // Ensure consistent width across all containers + } + } + +} + diff --git a/src/components/common/ConfirmModal.js b/src/components/common/ConfirmModal.js new file mode 100644 index 000000000..79fe8d4df --- /dev/null +++ b/src/components/common/ConfirmModal.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal } from 'semantic-ui-react'; +import { Button,IconAlertCircle } from 'hds-react'; + +function ConfirmModal({ openConfirmModal,headerText, contentText, button1Text, button2Text, onButtonPress1, onButtonPress2, style, buttonStyle1, buttonStyle2 }) { + return ( + + + + {contentText} + + + + + + + ); +} + +ConfirmModal.propTypes = { + openConfirmModal: PropTypes.bool, + headerText: PropTypes.string, + contentText: PropTypes.string, + button1Text: PropTypes.string, + button2Text: PropTypes.string, + onButtonPress1: PropTypes.func, + onButtonPress2: PropTypes.func, + style: PropTypes.string, + buttonStyle1: PropTypes.string, + buttonStyle2: PropTypes.string +}; + +export default ConfirmModal; \ No newline at end of file diff --git a/src/components/common/colors.scss b/src/components/common/colors.scss index c57f58f0a..d08c007e1 100644 --- a/src/components/common/colors.scss +++ b/src/components/common/colors.scss @@ -24,7 +24,6 @@ $light-gray4: #E0E0E0; $accessible-grey: #757575; - $red: #c4123e; $red2: #dc3545; @@ -87,6 +86,7 @@ $color-tram-light: #dff7eb; $color-tram-medium-light: #a3e3c2; $color-tram-dark: #006631; $color-black: #000; +$color-gray: #B3B3B3; $color-white: #fff; $color-black-5: #F2F2F2; $color-black-10: #e5e5e5; @@ -135,12 +135,15 @@ $color_phase_approval:#bd9650; $color_phase_inception:#9ec8eb; $color_error_hds:#bd2719; +$size1:0.063rem; $size4:0.25rem; +$size5:0.313rem; $size8:0.5rem; $size10:0.625em; $size12:0.75rem; $size13:0.8125rem; $size14:0.875rem; +$size15:0.938rem; $size16:1rem; $size17:1.0625rem; $size18:1.125rem; diff --git a/src/components/input/CustomCheckbox.js b/src/components/input/CustomCheckbox.js index 5907d37ce..492653485 100644 --- a/src/components/input/CustomCheckbox.js +++ b/src/components/input/CustomCheckbox.js @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types'; import { getFormValues } from 'redux-form' import { EDIT_PROJECT_TIMETABLE_FORM } from '../../constants' import { getFieldAutofillValue } from '../../utils/projectAutofillUtils' import { useSelector } from 'react-redux' -import { Checkbox } from 'hds-react' +import { Checkbox,Button,Notification } from 'hds-react' +import { useTranslation } from 'react-i18next' const CustomCheckbox = ({ input: { name, value, onChange }, @@ -14,8 +16,10 @@ const CustomCheckbox = ({ disabled, updated, formName, - display + display, + isProjectTimetableEdit }) => { + const { t } = useTranslation() const formValues = useSelector(getFormValues(formName ? formName : EDIT_PROJECT_TIMETABLE_FORM)) const notDisabledBoxes = name === "kaavaluonnos_lautakuntaan_1" || name === "periaatteet_lautakuntaan_1" || name === "jarjestetaan_periaatteet_esillaolo_1" || name === "jarjestetaan_luonnos_esillaolo_1" @@ -62,20 +66,73 @@ const CustomCheckbox = ({ onChange(!checked) } - return ( - - ) + if(isProjectTimetableEdit){ + return ( + <> + {checked + ? + <> +
+ {t('deadlines.dates-confirmed')} +
+ {display !== 'readonly_checkbox' && +
+ +
+ } + + : + <> + + {t('deadlines.dates-are-preliminary')} + + {display !== 'readonly_checkbox' && + + } + + } + + ) + } + else{ + return ( + + ) + } } +CustomCheckbox.propTypes = { + input: PropTypes.shape({ + name: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), + onChange: PropTypes.func, + }), + meta: PropTypes.shape({ + error: PropTypes.string, + }), + autofillRule: PropTypes.string, + label: PropTypes.string, + className: PropTypes.string, + disabled: PropTypes.bool, + updated: PropTypes.object, + formName: PropTypes.string, + display: PropTypes.string, + isProjectTimetableEdit: PropTypes.bool +}; + export default CustomCheckbox diff --git a/src/components/input/CustomField.js b/src/components/input/CustomField.js index e518c56d6..85e6b5747 100644 --- a/src/components/input/CustomField.js +++ b/src/components/input/CustomField.js @@ -182,6 +182,7 @@ class CustomField extends Component { attributeData={attributeData} phaseIsClosed={phaseIsClosed} isTabActive={this.props.isTabActive} + fieldDisabled={this.props.disabled} /> ) } @@ -212,6 +213,7 @@ class CustomField extends Component { label={this.props?.field?.label} attributeData={attributeData} phaseIsClosed={phaseIsClosed} + fieldDisabled={this.props.disabled} /> ) } @@ -219,7 +221,8 @@ class CustomField extends Component { renderDate = props => { const { handleBlurSave, handleLockField, handleUnlockField, deadlines, field, lockField, fieldSetDisabled, insideFieldset, disabled, isProjectTimetableEdit, nonEditable, rollingInfo, modifyText, rollingInfoText, isCurrentPhase, selectedPhase, - attributeData, phaseIsClosed } = this.props + attributeData, phaseIsClosed, disabledDates, lomapaivat, dateTypes, maxMoveGroup, maxDateToMove, groupName, visGroups, visItems, + deadlineSections, formValues, confirmedValue, sectionAttributes } = this.props let current if (deadlines && deadlines.length > 0) { @@ -228,14 +231,37 @@ class CustomField extends Component { ) } + //temp fix because data is not added in backend to deadlines + if(typeof current === "undefined"){ + if(props.input.name === "viimeistaan_lausunnot_ehdotuksesta"){ + current = deadlines.find( + deadline => deadline.deadline.abbreviation === "E9" + ) + current.deadline.attribute = "viimeistaan_lausunnot_ehdotuksesta" + } + } + if (current && deadlines && deadlines.length > 0 || isProjectTimetableEdit) { return ( ) @@ -272,10 +298,10 @@ class CustomField extends Component { } renderSelect = props => { - const { choices, multiple_choice, placeholder_text, formName } = this.props.field + const { choices, multiple_choice, placeholder_text, autofill_rule } = this.props.field const { handleBlurSave, handleLockField, handleUnlockField, lockField, fieldSetDisabled, - insideFieldset, nonEditable, rollingInfo, modifyText, rollingInfoText, isCurrentPhase, selectedPhase, phaseIsClosed } = this.props - + insideFieldset, nonEditable, rollingInfo, modifyText, rollingInfoText, isCurrentPhase, selectedPhase, phaseIsClosed, + formValues, formName } = this.props return ( ) } @@ -332,8 +360,7 @@ class CustomField extends Component { } renderBooleanRadio = props => { - const { input, onRadioChange, defaultValue, disabled, nonEditable, rollingInfo, modifyText, rollingInfoText, isCurrentPhase, selectedPhase, phaseIsClosed } = this.props - + const { input, onRadioChange, defaultValue, disabled, nonEditable, rollingInfo, modifyText, rollingInfoText, isCurrentPhase, selectedPhase, phaseIsClosed, isProjectTimetableEdit } = this.props return ( ) @@ -477,8 +505,7 @@ class CustomField extends Component { } renderCustomCheckbox = props => { - const { field, formName, disabled } = this.props - + const { field, formName, disabled,isProjectTimetableEdit } = this.props return ( ) } @@ -785,14 +813,14 @@ class CustomField extends Component { CustomField.propTypes = { disabled: PropTypes.bool, - field:PropTypes.object, - input:PropTypes.func, - onRadioChange:PropTypes.func, - defaultValue:PropTypes.bool, - formName:PropTypes.string, - attributeData:PropTypes.object, - deadlines:PropTypes.object, - isProjectTimetableEdit:PropTypes.bool, + field: PropTypes.object, + input: PropTypes.func, + onRadioChange: PropTypes.func, + defaultValue: PropTypes.bool, + formName: PropTypes.string, + attributeData: PropTypes.object, + deadlines: PropTypes.array, + isProjectTimetableEdit: PropTypes.bool, nonEditable: PropTypes.bool, rollingInfo: PropTypes.bool, modifyText: PropTypes.string, @@ -812,11 +840,31 @@ CustomField.propTypes = { PropTypes.bool, PropTypes.object ]), - isCurrentPhase:PropTypes.bool, + isCurrentPhase: PropTypes.bool, selectedPhase: PropTypes.number, phaseIsClosed: PropTypes.bool, checkLocked: PropTypes.func, - isTabActive: PropTypes.bool -} + isTabActive: PropTypes.bool, + disabledDates: PropTypes.array, + lomapaivat: PropTypes.array, + dateTypes: PropTypes.object, + deadlineSection: PropTypes.object, + maxMoveGroup: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string + ]), + maxDateToMove: PropTypes.string, + groupName: PropTypes.string, + visItems: PropTypes.array, + visGroups: PropTypes.array, + deadlineSections: PropTypes.array, + formValues: PropTypes.object, + confirmedValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + sectionAttributes: PropTypes.array +}; export default CustomField diff --git a/src/components/input/CustomInput.js b/src/components/input/CustomInput.js index b4bc00b9f..0588b6bc9 100644 --- a/src/components/input/CustomInput.js +++ b/src/components/input/CustomInput.js @@ -11,7 +11,7 @@ import RollingInfo from '../input/RollingInfo' import {useFocus} from '../../hooks/useRefFocus' import { useIsMount } from '../../hooks/IsMounted' -const CustomInput = ({ input, meta: { error }, ...custom }) => { +const CustomInput = ({ fieldData, input, meta: { error }, ...custom }) => { const [readonly, setReadOnly] = useState({name:"",read:false}) const [hasError,setHasError] = useState(false) const [editField,setEditField] = useState(false) @@ -194,7 +194,9 @@ const CustomInput = ({ input, meta: { error }, ...custom }) => { originalData = false } - if (event.target.value !== originalData) { + const isRequired = fieldData.required + + if (event.target.value !== originalData && (!isRequired || (isRequired && event.target.value !== ""))) { //prevent saving if locked if (!readonly) { //Sent call to save changes diff --git a/src/components/input/DeadlineInfoText.js b/src/components/input/DeadlineInfoText.js index 3298d8562..56c65bed7 100644 --- a/src/components/input/DeadlineInfoText.js +++ b/src/components/input/DeadlineInfoText.js @@ -6,10 +6,11 @@ import { useSelector, useDispatch } from 'react-redux' import dayjs from 'dayjs' import { isNumber, isBoolean, isArray } from 'lodash' import PropTypes from 'prop-types' +import { Notification } from 'hds-react' const DeadlineInfoText = props => { const formValues = useSelector(getFormValues(EDIT_PROJECT_TIMETABLE_FORM)) - let inputValue = props.input && props.input.value + let inputValue = props.input?.value let readonlyValue const [current, setCurrent] = useState() @@ -61,34 +62,83 @@ const DeadlineInfoText = props => { } }, []) - let value + const calculateDaysBetweenDates = (startDate, endDate) => { + const start = new Date(startDate); + const end = new Date(endDate); + const differenceInMilliseconds = end - start; + const differenceInDays = differenceInMilliseconds / (1000 * 60 * 60 * 24); + return differenceInDays; + } + + const determineFieldValue = (current, props) => { + + if (isNumber(current) || isBoolean(current)) { + return current + } - if (isNumber(current) || isBoolean(current)) { - value = current - } else { + if(props.input.name.includes("nahtavillaolopaivien_lukumaara")){ + const regex = /_x(\d+)/; + const match = props.input.name.match(regex); + const index = match ? "_"+match[1] : ""; + let start = formValues["milloin_ehdotuksen_nahtavilla_alkaa_iso"+index] ?? formValues["milloin_ehdotuksen_nahtavilla_alkaa_pieni"+index] + let end = formValues["milloin_ehdotuksen_nahtavilla_paattyy"+index] + return calculateDaysBetweenDates(start, end) + } // Expect date in value - value = current && dayjs(current).format('DD.MM.YYYY') - if (value === 'Invalid Date') { + const dateValue = current && dayjs(current).format('DD.MM.YYYY') + if (dateValue === 'Invalid Date') { if(isArray(current) && props?.fieldData?.autofill_readonly && props?.fieldData?.type === "readonly" && props?.fieldData?.unit === "päivää"){ //Fixes situation if int has at somepoint on old project been converted to date/array of some sort because of bug and converts it back to int - value = props?.meta?.initial - } - else{ - value = current + return props?.meta?.initial } + return current } + return dateValue } + const value = determineFieldValue(current, props) + + const phaseMap = { + periaatteista: "Periaatteet", + oas: "OAS", + luonnos: "Luonnos" + }; + + const phaseKey = Object.keys(phaseMap).find(key => props.input.name.includes(key)); + const phase = phaseMap[phaseKey]; + return ( -
- {props.label} {value} -
- ) + <> + {phase && ( + + {`${phase}${phase === "Luonnos" ? "" : "-"}vaiheen tilaisuus: ${value}.`} + + )} + {props.input.name.includes("nahtavillaolopaivien_lukumaara") ? +

{props.label}: {value} pv

+ : {props.label + ': '}{value} + } + + ); + } DeadlineInfoText.propTypes = { fieldData:PropTypes.object, meta: PropTypes.object, + input: PropTypes.shape({ + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + name: PropTypes.string, + }), + label: PropTypes.string, } export default DeadlineInfoText diff --git a/src/components/input/DeadlineInput.js b/src/components/input/DeadlineInput.js index 515dacb09..2d7b1a35a 100644 --- a/src/components/input/DeadlineInput.js +++ b/src/components/input/DeadlineInput.js @@ -1,16 +1,20 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' import inputUtils from '../../utils/inputUtils' import { useTranslation } from 'react-i18next' -import { TextInput, IconAlertCircle } from 'hds-react' +import { TextInput, DateInput, IconAlertCircle } from 'hds-react' import { getFieldAutofillValue } from '../../utils/projectAutofillUtils' -import { useSelector } from 'react-redux' +import timeUtil from '../../utils/timeUtil' +import { useSelector,useDispatch } from 'react-redux' import { getFormValues } from 'redux-form' import { EDIT_PROJECT_TIMETABLE_FORM } from '../../constants' +import { updateDateTimeline } from '../../actions/projectActions'; +import { validatedSelector } from '../../selectors/projectSelector'; const DeadLineInput = ({ input, error, + attributeData, currentDeadline, editable, type, @@ -18,42 +22,24 @@ const DeadLineInput = ({ placeholder, className, autofillRule, - timeTableDisabled + timeTableDisabled, + dateTypes, + deadlineSections, + confirmedValue, + sectionAttributes }) => { - - const { t } = useTranslation() - let inputValue = input.value - if (autofillRule) { - const formValues = useSelector(getFormValues(EDIT_PROJECT_TIMETABLE_FORM)) - - if (autofillRule && autofillRule.length > 0) { - inputValue = getFieldAutofillValue( - autofillRule, - formValues, - input.name, - EDIT_PROJECT_TIMETABLE_FORM - ) - } - } - if ( inputValue === null ) { - inputValue = '' - } - let currentDeadlineDate = '' - - if ( currentDeadline && currentDeadline.date ) [ - currentDeadlineDate = currentDeadline.date - ] + const dispatch = useDispatch(); + const { t } = useTranslation() + const validated = useSelector(validatedSelector); + const formValues = useSelector(getFormValues(EDIT_PROJECT_TIMETABLE_FORM)) + const [currentValue, setCurrentValue] = useState("") + const [disabledState, setDisabledState] = useState(true) - const [currentValue, setCurrentValue] = useState( - currentDeadline ? currentDeadlineDate : inputValue - ) let currentError const generated = currentDeadline && currentDeadline.generated - - const [valueGenerated, setValueGenerated] = useState(generated) if (currentDeadline && currentDeadline.is_under_min_distance_previous) { @@ -85,47 +71,240 @@ const DeadLineInput = ({ currentClassName = `${currentClassName} error-border` } + useEffect(() => { + let inputValue = input.value + if (autofillRule) { + + if (autofillRule && autofillRule.length > 0) { + inputValue = getFieldAutofillValue( + autofillRule, + formValues, + input.name, + EDIT_PROJECT_TIMETABLE_FORM + ) + } + } + + if ( inputValue === null ) { + inputValue = '' + } + let currentDeadlineDate = '' + const index = input.name.match(/\d+/); + const indexString = index ? "_"+index[0] : ''; + + if(currentDeadline?.deadline?.attribute && attributeData[currentDeadline.deadline.attribute]){ + currentDeadlineDate = attributeData[currentDeadline.deadline.attribute] + } + else if(input.name === 'luonnosaineiston_maaraaika'+indexString && attributeData['kaavaluonnos_kylk_aineiston_maaraaika'+indexString]){ + inputValue = attributeData['kaavaluonnos_kylk_aineiston_maaraaika'+indexString] + } + else if (currentDeadline?.date) { + currentDeadlineDate = currentDeadline.date + } + else if(input.name === "hyvaksymispaatos_pvm" && attributeData["hyvaksyminenvaihe_paattyy_pvm"]){ + attributeData["hyvaksyminenvaihe_paattyy_pvm"] = inputValue === '' ? attributeData["hyvaksyminenvaihe_paattyy_pvm"] : inputValue + } + + setCurrentValue(currentDeadline ? currentDeadlineDate : inputValue ) + setDisabledState(typeof timeTableDisabled !== "undefined" ? timeTableDisabled : disabled) + },[]) + + useEffect(() => { + //Update calendar values when value has changed + if(currentValue !== input.value){ + setCurrentValue(input.value); + } + }, [input.value,formValues]) + + useEffect(() => { + setDisabledState(formValues[confirmedValue]) + },[formValues[confirmedValue]]) + + const getInitialMonth = (dateString) => { + let date; + if(attributeData['voimaantulovaihe_paattyy_pvm'] && (input.name === "tullut_osittain_voimaan_pvm" || input.name === "voimaantulo_pvm" || input.name === "kumottu_pvm" || input.name === "rauennut")){ + date = new Date(attributeData['voimaantulovaihe_paattyy_pvm']); + } + else if(input.name === "hyvaksymispaatos_pvm" && attributeData["hyvaksyminenvaihe_paattyy_pvm"]){ + date = new Date(attributeData['hyvaksyminenvaihe_paattyy_pvm']); + } + else if (dateString) { + date = new Date(dateString); + } + else { + date = new Date(); // Use current date if no date string is provided + } + return date; + } + + const formatDate = (date) => { + const year = date.getFullYear(); + // Pad the month and day with leading zeros if needed + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + + + const isDisabledDate = (date) => { + //20 years is the calendars range to check work days, holidays etc from current date + const twentyYearsAgo = new Date(); + twentyYearsAgo.setFullYear(twentyYearsAgo.getFullYear() - 20); + const twentyYearsLater = new Date(); + twentyYearsLater.setFullYear(twentyYearsLater.getFullYear() + 20); + const ehdotusNahtavillaolo = currentDeadline?.deadline?.phase_name === "Ehdotus" && currentDeadline?.deadline?.deadlinegroup?.includes('nahtavillaolo') + const datesToDisable = timeUtil.calculateDisabledDates(ehdotusNahtavillaolo,attributeData?.kaavaprosessin_kokoluokka,dateTypes,input.name,formValues,sectionAttributes,currentDeadline) + if (date < twentyYearsAgo || date > twentyYearsLater) { + return false; + } + return !datesToDisable.includes(formatDate(date)); + } + + const formatDateToYYYYMMDD = (date) => { + if(typeof date !== 'object' && date?.includes('.')){ + const dateParts = date.split("."); + const eventDate = new Date(`${dateParts[2]}-${dateParts[1]}-${dateParts[0]}`); + const year = eventDate.getFullYear(); + const month = ("0" + (eventDate.getMonth() + 1)).slice(-2); // Months are 0-based, so add 1 and pad with 0 if necessary + const day = ("0" + eventDate.getDate()).slice(-2); // Pad with 0 if necessary + return `${year}-${month}-${day}`; + } + else { + return date; + } + }; + + const formatDateToDMYYYY = (dateString) => { + // Required by hds-DateInput + if(typeof dateString !== 'object' && dateString?.includes('-')){ + const removeZero = (datePart) => datePart.startsWith('0') ? datePart.slice(1) : datePart; + const dateParts = dateString.split("-"); + return `${removeZero(dateParts[2])}.${removeZero(dateParts[1])}.${dateParts[0]}`; + } else { + return dateString; + } + } + + const handleDateChange = (formattedDate) => { + try { + let field = input.name; + setCurrentValue(formattedDate) + dispatch(updateDateTimeline(field,formattedDate,formValues,false,deadlineSections)); + } catch (error) { + console.error('Validation error:', error); + } + }; return ( -
- { - const value = event.target.value - setCurrentValue(value) - input.onChange(value) - }} - className={currentClassName} - onBlur={() => { - if (input.value !== input.defaultValue) { - setValueGenerated(false) - } else { - setValueGenerated(true) - } - }} - /> - {editable && valueGenerated ? ( + <> +
+ {type === 'date' ? + !validated ? + { + let formattedDate + const dateString = event; + if (dateString.includes('.')) { + formattedDate = formatDateToYYYYMMDD(dateString); + } else { + formattedDate = dateString; + } + handleDateChange(formattedDate); + }} + className={currentClassName} + onBlur={() => { + if (input.value !== input.defaultValue) { + setValueGenerated(false) + } else { + setValueGenerated(true) + } + }} + /> : "Ladataan..." + : + { + const value = event.target.value + setCurrentValue(value) + input.onChange(value) + }} + className={currentClassName} + onBlur={() => { + if (input.value !== input.defaultValue) { + setValueGenerated(false) + } else { + setValueGenerated(true) + } + }} + /> + } +
+ {editable && valueGenerated && !EDIT_PROJECT_TIMETABLE_FORM ? ( {t('deadlines.estimated')} ) : ( '' )} - {editable && hasError && ( + {editable && hasError && !EDIT_PROJECT_TIMETABLE_FORM && (
{currentError}{' '}
)} -
+{/* {warning.warning && ( + + Seuraavien päivämäärien siirtäminen ei ole mahdollista, koska minimietäisyys viereisiin etappeihin on täyttynyt. + {warning.response.conflicting_deadline}. Asetettu seuraava kelvollinen päivä {warning.response.suggested_date} + )} */} + ) } DeadLineInput.propTypes = { input: PropTypes.object.isRequired, - timeTableDisabled: PropTypes.bool + error: PropTypes.string, + attributeData: PropTypes.object, + currentDeadline: PropTypes.object, + editable: PropTypes.bool, + type: PropTypes.string, + disabled: PropTypes.bool, + placeholder: PropTypes.string, + className: PropTypes.string, + autofillRule: PropTypes.array, + timeTableDisabled: PropTypes.bool, + dateTypes: PropTypes.object, + deadlineSection: PropTypes.object, + maxMoveGroup: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string + ]), + maxDateToMove: PropTypes.string, + groupName: PropTypes.string, + visGroups: PropTypes.array, + visItems: PropTypes.array, + deadlineSections: PropTypes.array, + confirmedValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + sectionAttributes: PropTypes.array } export default DeadLineInput diff --git a/src/components/input/FieldSet.js b/src/components/input/FieldSet.js index c51741ff4..c62d78281 100644 --- a/src/components/input/FieldSet.js +++ b/src/components/input/FieldSet.js @@ -426,9 +426,9 @@ const mapStateToProps = state => ({ FieldSet.propTypes = { unlockAllFields:PropTypes.func, saving: PropTypes.bool, - fields: PropTypes.object, + fields: PropTypes.array, lastSaved: PropTypes.object, - updateField: PropTypes.object, + updateField: PropTypes.bool, attributeData: PropTypes.object, updated: PropTypes.object, phaseIsClosed: PropTypes.bool, diff --git a/src/components/input/FormField.js b/src/components/input/FormField.js index b15f4f9c6..13cd74cdd 100644 --- a/src/components/input/FormField.js +++ b/src/components/input/FormField.js @@ -41,6 +41,18 @@ const FormField = ({ phaseIsClosed, hasEditRights, isTabActive, + disabledDates, + lomapaivat, + dateTypes, + deadlineSection, + maxMoveGroup, + maxDateToMove, + groupName, + visGroups, + visItems, + deadlineSections, + confirmedValue, + sectionAttributes, ...rest }) => { const [lockStatus, setLockStatus] = useState({}) @@ -120,6 +132,18 @@ const FormField = ({ selectedPhase={selectedPhase} phaseIsClosed={phaseIsClosed} isTabActive={isTabActive} + disabledDates={disabledDates} + lomapaivat={lomapaivat} + dateTypes={dateTypes} + deadlineSection={deadlineSection} + maxMoveGroup={maxMoveGroup} + maxDateToMove={maxDateToMove} + groupName={groupName} + visGroups={visGroups} + visItems={visItems} + deadlineSections={deadlineSections} + confirmedValue={confirmedValue} + sectionAttributes={sectionAttributes} /> ) } @@ -136,7 +160,7 @@ const FormField = ({ const isCheckBox = field && (field.display === 'checkbox' || field.display === 'readonly_checkbox') - const isDeadlineInfo = field && field.display === 'readonly' + const isDeadlineInfo = field && field.display === 'readonly' && field.type !== 'choice' const syncError = syncronousErrors && syncronousErrors[field.name] @@ -203,6 +227,7 @@ const FormField = ({ } const renderNormalField = () => { + const timetableBoolean = isProjectTimetableEdit && field.type === "boolean" const status = lockStatus let title = (field.character_limit ? `${field.label} (${t('project.char-limit', { amount: field.character_limit })})` @@ -222,7 +247,7 @@ const FormField = ({ } ${highlightStyle}`} > {highlightStyle === "yellow" ?
{highlightedTag}
: ''} - {!isOneLineField && ( + {!isOneLineField && !timetableBoolean && (
@@ -784,13 +810,24 @@ class ProjectEditPage extends Component { } ProjectEditPage.propTypes = { - currentProject:PropTypes.object, + currentProject: PropTypes.object, project: PropTypes.object, schema: PropTypes.object, resetFormErrors: PropTypes.func, unlockAllFields: PropTypes.func, location: PropTypes.object, - switchDisplayedPhase: PropTypes.func + switchDisplayedPhase: PropTypes.func, + formValues: PropTypes.object, + fetchDisabledDatesStart: PropTypes.func, + formSelector: PropTypes.object, + reset: PropTypes.func, + resetAttributeData: PropTypes.func, + documents: PropTypes.array, + disabledDates: PropTypes.object, + showFloorAreaForm: PropTypes.bool, + showTimetableForm: PropTypes.bool, + attribute_data: PropTypes.object, + saveProjectFloorArea: PropTypes.func, } const mapStateToProps = state => { @@ -813,7 +850,9 @@ const mapStateToProps = state => { timetableSavedSelector: timetableSavedSelector(state), documents: documentsSelector(state), showTimetableForm:showTimetableSelector(state), - showFloorAreaForm:showFloorAreaSelector(state) + showFloorAreaForm:showFloorAreaSelector(state), + disabledDates: selectDisabledDates(state), + formSelector: editProjectTimetableFormSelector(state) } } @@ -841,7 +880,10 @@ const mapDispatchToProps = { showTimetable, showFloorArea, setLastSaved, - resetFormErrors + resetFormErrors, + fetchDisabledDatesStart, + reset, + resetAttributeData } export default withRouter( diff --git a/src/components/projectEdit/quickNav/QuickNav.js b/src/components/projectEdit/quickNav/QuickNav.js index ba3518e72..d6001ae7b 100644 --- a/src/components/projectEdit/quickNav/QuickNav.js +++ b/src/components/projectEdit/quickNav/QuickNav.js @@ -476,8 +476,8 @@ export default function QuickNav({ } QuickNav.propTypes = { - phase: PropTypes.object, + phase: PropTypes.number, documentIndex: PropTypes.number, - locationSearch: PropTypes.object, + locationSearch: PropTypes.string, currentSchema: PropTypes.object } diff --git a/src/hocs/withValidateDate.js b/src/hocs/withValidateDate.js new file mode 100644 index 000000000..53c450076 --- /dev/null +++ b/src/hocs/withValidateDate.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { useValidateDate } from '../utils/dateUtils'; + +const withValidateDate = (WrappedComponent) => { + //Hook util alternative for Class components + const WithValidateDate = (props) => { + const validateDate = useValidateDate(); + return ; + }; + + WithValidateDate.displayName = `WithValidateDate(${getDisplayName(WrappedComponent)})`; + + return WithValidateDate; + }; + + function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; + } + + export default withValidateDate; \ No newline at end of file diff --git a/src/reducers/projectReducer.js b/src/reducers/projectReducer.js index 1959ceb8e..d0d3324c2 100644 --- a/src/reducers/projectReducer.js +++ b/src/reducers/projectReducer.js @@ -80,9 +80,21 @@ import { UPDATE_FLOOR_VALUES, FORM_ERROR_LIST, RESET_FORM_ERRORS, - SET_ATTRIBUTE_DATA + SET_ATTRIBUTE_DATA, + FETCH_DISABLED_DATES_START, + FETCH_DISABLED_DATES_SUCCESS, + FETCH_DISABLED_DATES_FAILURE, + SET_DATE_VALIDATION_RESULT, + REMOVE_DEADLINES, + VALIDATE_DATE, + UPDATE_DATE_TIMELINE, + RESET_ATTRIBUTE_DATA, + UPDATE_PROJECT_FAILURE } from '../actions/projectActions' +import timeUtil from '../utils/timeUtil' +import objectUtil from '../utils/objectUtil' + export const initialState = { projects: [], totalProjects: null, @@ -127,13 +139,144 @@ export const initialState = { lastModified:false, updatedFloorValue:{}, formErrorList:[], - updateField:false + updateField:false, + loading: false, + disabledDates: {}, + error: null, + dateValidationResult: {valid: false, result: {}}, + validated:false } export const reducer = (state = initialState, action) => { switch (action.type) { + case UPDATE_DATE_TIMELINE: { + const { field, newDate, formValues, isAdd, deadlineSections } = action.payload; + + // Create a copy of the state and attribute_data + let updatedAttributeData + if(formValues){ + updatedAttributeData = formValues + } + else{ + updatedAttributeData = { + ...state.currentProject.attribute_data, // Shallow copy of attribute_data + }; + } + const projectSize = updatedAttributeData?.kaavaprosessin_kokoluokka + //Remove all keys that are still hidden in vistimeline so they are not moved in data and later saved + const filteredAttributeData = objectUtil.filterHiddenKeys(updatedAttributeData); + const moveToPast = filteredAttributeData[field] > newDate; + //Save oldDate for comparison in checkforDecreasingValues + const oldDate = filteredAttributeData[field]; + //Sort array by date + const origSortedData = timeUtil.sortObjectByDate(filteredAttributeData); + const newDateObj = new Date(newDate); + // Update the specific date at the given field + filteredAttributeData[field] = timeUtil.formatDate(newDateObj); + if(field === "hyvaksymispaatos_pvm" && filteredAttributeData["hyvaksyminenvaihe_paattyy_pvm"]){ + filteredAttributeData["hyvaksyminenvaihe_paattyy_pvm"] = timeUtil.formatDate(newDateObj); + } + else if (field === "tullut_osittain_voimaan_pvm" || field === "voimaantulo_pvm" || field === "kumottu_pvm" || field === "rauennut") { + // Find the highest date among the specified fields + const highestDate = timeUtil.getHighestDate(filteredAttributeData); + // Modify the end date of voimaantulovaihe if any of the dates are changed and the new date is higher + if ((highestDate) || (!highestDate && newDate)) { + const higherDate = highestDate ? highestDate : newDate; + filteredAttributeData["voimaantulovaihe_paattyy_pvm"] = higherDate; + } + } + // Generate array from updatedAttributeData for comparison + const updateAttributeArray = objectUtil.generateDateStringArray(filteredAttributeData) + //Compare for changes with dates in order sorted array + const changes = objectUtil.compareAndUpdateArrays(origSortedData,updateAttributeArray,deadlineSections) + //Find out is next date below minium and add difference of those days to all values after and move them forward + const decreasingValues = objectUtil.checkForDecreasingValues(changes,isAdd,field,state.disabledDates,oldDate,newDate,moveToPast,projectSize); + //Add new values from array to updatedAttributeData object + objectUtil.updateOriginalObject(filteredAttributeData,decreasingValues) + //Updates viimeistaan lausunnot values to paattyy if paattyy date is greater + timeUtil.compareAndUpdateDates(filteredAttributeData) + // Return the updated state with the modified currentProject and attribute_data + return { + ...state, + currentProject: { + ...state.currentProject, + attribute_data: filteredAttributeData, + }, + }; + } + + case UPDATE_PROJECT_FAILURE: { + //Update dates that were returned unvalid from backend + const { errorData } = action.payload; + const dateRegex = /\d{1,2}\.\d{1,2}\.\d{4}/; + + // Create a copy of attribute_data to modify + const updatedAttributeData = { ...state.currentProject.attribute_data }; + + // Apply date format corrections based on errorData + for (const key in updatedAttributeData) { + if (errorData?.[key]) { + const dateMatch = errorData[key].find(msg => dateRegex.test(msg)); + if (dateMatch) { + const date = dateMatch.match(dateRegex)[0]; + const [day, month, year] = date.split('.'); + updatedAttributeData[key] = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + } + } + } + + return { + ...state, + currentProject: { + ...state.currentProject, + attribute_data: updatedAttributeData, + } + }; + } + + case REMOVE_DEADLINES:{ + return { + ...state, + currentProject: { + ...state.currentProject, + deadlines: state.currentProject.deadlines.filter(deadline => !action.payload.includes(deadline.deadline.attribute)), + }, + }; + } + + case VALIDATE_DATE: { + return { + ...state, + validated: true + }; + } + + case SET_DATE_VALIDATION_RESULT: { + return { + ...state, + dateValidationResult: { + ...state.dateValidationResult, + valid: action.payload.valid, + result: action.payload.result + }, + validated: false + }; + } + + case FETCH_DISABLED_DATES_START: { + return { ...state, loading: true, error: null }; + } + + case FETCH_DISABLED_DATES_SUCCESS: { + return { ...state, loading: false, disabledDates: action.payload}; + } + + case FETCH_DISABLED_DATES_FAILURE:{ + return { ...state, loading: false, error: action.payload }; + } + case SET_ATTRIBUTE_DATA: { const { fieldName, data } = action.payload let updatedAttributeData @@ -155,6 +298,17 @@ export const reducer = (state = initialState, action) => { } } + case RESET_ATTRIBUTE_DATA: { + const { initialData } = action.payload + return { + ...state, + currentProject: { + ...state.currentProject, + attribute_data: { ...initialData } + } + } + } + case RESET_FORM_ERRORS: { return{ ...state, @@ -496,9 +650,25 @@ export const reducer = (state = initialState, action) => { case UPDATE_PROJECT: case FETCH_PROJECT_SUCCESSFUL: { + // Clone the payload to avoid direct mutation + const updatedPayload = { ...action.payload }; + + //When project is fetched it has all phase data, hide phases from data that are not in use when project is create + //(like oas esillaolo 2,3 etc) + updatedPayload.attribute_data = objectUtil.filterHiddenKeys(updatedPayload.attribute_data); + + // Check conditions and update attribute_data if necessary + //Ehdotus Add the key with a value of true because first one should be always visible at start + // if not true data is not visible for modification on edit timetable side panel + if (updatedPayload?.attribute_data?.kaavaprosessin_kokoluokka === "XL" || updatedPayload?.attribute_data?.kaavaprosessin_kokoluokka === "L"){ + if(updatedPayload?.attribute_data["kaavaehdotus_lautakuntaan_1"] === undefined) { + updatedPayload.attribute_data["kaavaehdotus_lautakuntaan_1"] = true; + } + } + return { ...state, - currentProject: action.payload, + currentProject: updatedPayload, saving: false } } diff --git a/src/sagas/projectSaga.js b/src/sagas/projectSaga.js index 2742ed2d7..dc8db8aca 100644 --- a/src/sagas/projectSaga.js +++ b/src/sagas/projectSaga.js @@ -1,5 +1,5 @@ import axios from 'axios' -import { takeLatest, put, all, call, select } from 'redux-saga/effects' +import { takeLatest, put, all, call, select, takeEvery } from 'redux-saga/effects' import { isEqual, isEmpty, isArray } from 'lodash' import { push } from 'connected-react-router' import { @@ -113,12 +113,20 @@ import { projectFileUploadSuccessful, GET_ATTRIBUTE_DATA, SET_ATTRIBUTE_DATA, - setAttributeData + setAttributeData, + FETCH_DISABLED_DATES_START, + fetchDisabledDatesSuccess, + fetchDisabledDatesFailure, + VALIDATE_DATE, + setDateValidationResult, + VALIDATE_PROJECT_TIMETABLE, + UPDATE_PROJECT_FAILURE } from '../actions/projectActions' import { startSubmit, stopSubmit, setSubmitSucceeded } from 'redux-form' import { error } from '../actions/apiActions' import { setAllEditFields } from '../actions/schemaActions' import projectUtils from '../utils/projectUtils' +import errorUtil from '../utils/errorUtil' import { projectApi, projectDeadlinesApi, @@ -133,7 +141,9 @@ import { attributesApiUnlock, attributesApiUnlockAll, pingApi, - getAttributeDataApi + getAttributeDataApi, + projectDateTypesApi, + projectDateValidateApi } from '../utils/api' import { usersSelector } from '../selectors/userSelector' import { @@ -161,6 +171,7 @@ export default function* projectSaga() { takeLatest(SAVE_PROJECT_FLOOR_AREA, saveProjectFloorArea), takeLatest(SAVE_PROJECT_FLOOR_AREA_SUCCESSFUL, saveProjectFloorAreaSuccessful), takeLatest(SAVE_PROJECT_TIMETABLE, saveProjectTimetable), + takeLatest(VALIDATE_PROJECT_TIMETABLE,validateProjectTimetable), takeLatest(SAVE_PROJECT_TIMETABLE_SUCCESSFUL, saveProjectTimetableSuccessful), takeLatest(SAVE_PROJECT, saveProject), takeLatest(SET_LAST_SAVED, setLastSaved), @@ -193,10 +204,39 @@ export default function* projectSaga() { takeLatest(FETCH_ONHOLD_PROJECTS, fetchOnholdProjects), takeLatest(FETCH_ARCHIVED_PROJECTS, fetchArchivedProjects), takeLatest(GET_ATTRIBUTE_DATA, getAttributeData), - takeLatest(SET_ATTRIBUTE_DATA, setAttributeData) + takeLatest(SET_ATTRIBUTE_DATA, setAttributeData), + takeLatest(FETCH_DISABLED_DATES_START, getProjectDisabledDeadlineDates), + takeLatest(VALIDATE_DATE, validateDate) ]) } +function* validateDate({payload}) { + try { + const query = { + identifier: payload.field, + project: payload.projectName, + date: payload.date, + }; + const result = yield call(projectDateValidateApi.get, { query }); + const valid = result.conflicting_deadline === null && result.error_reason === null && result.suggested_date === null ? true : false; + yield put(setDateValidationResult(valid,result)) + } catch (e) { + yield put(error(e)) + } +} + +export function* watchValidateDate() { + yield takeEvery(VALIDATE_DATE, validateDate); +} + +function* getProjectDisabledDeadlineDates() { + try { + const dates = yield call(projectDateTypesApi.get); + yield put(fetchDisabledDatesSuccess(dates)); + } catch (e) { + yield put(fetchDisabledDatesFailure(e)); + } +} function* getAttributeData(data) { const project_name = data.payload.projectName; @@ -524,6 +564,28 @@ function* createProject() { } } +const adjustDeadlineData = (attributeData, allAttributeData) => { + Object.keys(allAttributeData).forEach(key => { + if (key.includes("periaatteet_esillaolo") || + key.includes("mielipiteet_periaatteista") || + key.includes("periaatteet_lautakunnassa") || + key.includes("oas_esillaolo") || + key.includes("mielipiteet_oas") || + key.includes("luonnosaineiston_maaraaika") || + key.includes("luonnos_esillaolo") || + key.includes("mielipiteet_luonnos") || + key.includes("milloin_kaavaluonnos_lautakunnassa") || + key.includes("milloin_kaavaehdotus_lautakunnassa") || + key.includes("ehdotus_nahtaville_aineiston_maaraaika") || + key.includes("milloin_ehdotuksen_nahtavilla_paattyy") || + key.includes("viimeistaan_lausunnot_ehdotuksesta") || + key.includes("milloin_tarkistettu_ehdotus_lautakunnassa")) { + attributeData[key] = attributeData[key] || allAttributeData[key] + } + }) + return attributeData +} + const getChangedAttributeData = (values, initial) => { let attribute_data = {} let errorValues = false @@ -615,41 +677,108 @@ function* saveProjectFloorArea() { yield put(saveProjectFloorAreaSuccessful(true)) yield put(setAllEditFields()) - yield put(toastr.success(i18.t('messages.timelines-successfully-saved'))) + toastr.success(i18.t('messages.timelines-successfully-saved')) } catch (e) { if (e?.code === "ERR_NETWORK") { - yield put(toastr.error(i18.t('messages.general-save-error'))) + toastr.error(i18.t('messages.general-save-error')) } yield put(stopSubmit(EDIT_FLOOR_AREA_FORM, e.response && e.response.data)) } } } -function* saveProjectTimetable() { + +function* validateProjectTimetable() { + // Remove success toastr before showing info + toastr.removeByType('success'); + // Show a loading icon at the start of the saga + toastr.info(i18.t('messages.checking-dates'), { + timeOut: 0, // Keep it showing until manually removed + removeOnHover: false, + showCloseButton: false, + }); yield put(startSubmit(EDIT_PROJECT_TIMETABLE_FORM)) - const { initial, values, registeredFields } = yield select( + const { initial, values } = yield select( editProjectTimetableFormSelector ) - const currentProject = yield select(currentProjectSelector) const currentProjectId = yield select(currentProjectIdSelector) if (values) { - let attribute_data = getChangedAttributeData(values, initial) - - if(attribute_data.oikaisukehoituksen_alainen_readonly){ - delete attribute_data.oikaisukehoituksen_alainen_readonly + let changedAttributeData = getChangedAttributeData(values, initial) + if(changedAttributeData.oikaisukehoituksen_alainen_readonly){ + delete changedAttributeData.oikaisukehoituksen_alainen_readonly } - - const deadlineAttributes = currentProject.deadline_attributes - - // Add missing fields as a null to payload since there are - // fields which can be hidden according the user selection. - // If old values are left, it will break the timelines. - deadlineAttributes.forEach(attribute => { - if (!registeredFields[attribute]) { - attribute_data = { ...attribute_data, [attribute]: null } + let attribute_data = adjustDeadlineData(changedAttributeData, values) + + try { + yield call( + projectApi.patch, + { attribute_data }, + { path: { id: currentProjectId } }, + ':id/?fake=true' + ) + // Remove the loading icon + toastr.removeByType('info'); + toastr.success(i18.t('messages.dates-confirmed'), { + timeOut: 10000, + removeOnHover: false, + showCloseButton: true, + }); + // All dates good no need to do anything + } catch (e) { + if (e?.code === "ERR_NETWORK") { + toastr.error(i18.t('messages.general-save-error')) } - }) + //Catch reached so dates were not correct, get days and update them to form + //from projectReducer UPDATE_PROJECT_FAILURE + // Get the error message string dynamically + const errorMessage = errorUtil.getErrorMessage(e?.response?.data); + // Remove loading icon and show error toastr + toastr.removeByType('info'); + // Display message in a toastr + toastr.info(i18.t('messages.error-with-dates'), errorMessage, { + timeOut: 10000, + removeOnHover: false, + showCloseButton: true, + preventDuplicates: true, + className: 'large-scrollable-toastr rrt-info' + }); + + //Show a message of a dates changed + // Get the message string dynamically + const message = errorUtil.getErrorMessage(e?.response?.data, 'date'); + // Display message in a toastr + toastr.warning(i18.t('messages.fixed-timeline-dates'), message, { + timeOut: 10000, + removeOnHover: false, + showCloseButton: true, + preventDuplicates: true, + className: 'large-scrollable-toastr rrt-warning' + }); + + // Dispatch failure action with error data for the reducer to handle date correction to timeline form + yield put({ + type: UPDATE_PROJECT_FAILURE, + payload: { errorData: e?.response?.data }, + }); + } + } +} + +function* saveProjectTimetable() { + yield put(startSubmit(EDIT_PROJECT_TIMETABLE_FORM)) + + const { initial, values } = yield select( + editProjectTimetableFormSelector + ) + const currentProjectId = yield select(currentProjectIdSelector) + + if (values) { + let changedAttributeData = getChangedAttributeData(values, initial) + if(changedAttributeData.oikaisukehoituksen_alainen_readonly){ + delete changedAttributeData.oikaisukehoituksen_alainen_readonly + } + let attribute_data = adjustDeadlineData(changedAttributeData, values) try { const updatedProject = yield call( @@ -664,20 +793,28 @@ function* saveProjectTimetable() { yield put(setAllEditFields()) if (!checkDeadlines(updatedProject.deadlines)) { - yield put(toastr.success(i18.t('messages.deadlines-successfully-saved'))) + toastr.success(i18.t('messages.deadlines-successfully-saved')) } else { - yield put( toastr.warning( i18.t('messages.deadlines-successfully-saved'), i18.t('messages.check-timetable') ) - ) } } catch (e) { if (e?.code === "ERR_NETWORK") { - yield put(toastr.error(i18.t('messages.general-save-error'))) + toastr.error(i18.t('messages.general-save-error')) } - yield put(stopSubmit(EDIT_PROJECT_TIMETABLE_FORM, e.response && e.response.data)) + yield put(stopSubmit(EDIT_PROJECT_TIMETABLE_FORM, e?.response?.data)) + // Get the error message string dynamically + const errorMessage = errorUtil.getErrorMessage(e?.response?.data); + + // Display the error message in a toastr + toastr.error(i18.t('messages.general-save-error'), errorMessage, { + timeOut: 0, + removeOnHover: false, + showCloseButton: true, + className: 'large-scrollable-toastr rrt-error' + }); } } } diff --git a/src/selectors/projectSelector.js b/src/selectors/projectSelector.js index 6c3b3261e..32121adb0 100644 --- a/src/selectors/projectSelector.js +++ b/src/selectors/projectSelector.js @@ -258,4 +258,19 @@ export const formErrorListSelector = createSelector( export const updateFieldSelector = createSelector( selectProject, project => project?.updateField +) + +export const selectDisabledDates = createSelector( + selectProject, + project => project?.disabledDates +) + +export const validatedSelector = createSelector( + selectProject, + project => project?.validated +) + +export const dateValidationResultSelector = createSelector( + selectProject, + project => project?.dateValidationResult ) \ No newline at end of file diff --git a/src/utils/api.js b/src/utils/api.js index eca36cc47..0c9ee11b9 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -50,6 +50,8 @@ export const documentsApi = new Api('/v1/projects/:id/documents/') export const phaseApi = new Api('/v1/phases/') export const projectApi = new Api('/v1/projects/') export const projectDeadlinesApi = new Api('/v1/deadlines/') +export const projectDateTypesApi = new Api("/v1/deadlines/date_types/") +export const projectDateValidateApi = new Api("/v1/deadlines/validate/") export const projectTypeApi = new Api('/v1/projecttypes/') export const projectSubtypeApi = new Api('/v1/projectsubtypes/') export const schemaApi = new Api('/v1/schemas/') diff --git a/src/utils/dateUtils.js b/src/utils/dateUtils.js new file mode 100644 index 000000000..935969e48 --- /dev/null +++ b/src/utils/dateUtils.js @@ -0,0 +1,50 @@ +import { useDispatch } from 'react-redux'; +import { validateDateAction } from '../actions/projectActions'; + +export const useValidateDate = () => { + const dispatch = useDispatch(); + //Util to call validateDateAction and return suggested date from backend if validation fails. + const validateDate = (field, projectName, formattedDate, setWarning) => { + return new Promise((resolve, reject) => { + dispatch(validateDateAction(field, projectName, formattedDate, (response) => { + if (response) { + let returnDate + if (response.error_reason !== null) { + // Show warning notification with suggested date and reasons + if (typeof setWarning === 'function') { + setWarning({ + warning: true, + response: { + reason: response.error_reason, + suggested_date: response.suggested_date, + conflicting_deadline: response.conflicting_deadline + } + }); + } + // Return suggested date + returnDate = response.suggested_date; + } else { + if (typeof setWarning === 'function') { + // Reset warning + setWarning({ + warning: false, + response: { + reason: "", + suggested_date: "", + conflicting_deadline: "" + } + }); + } + // Return valid date + returnDate = response.date + } + resolve(returnDate); + } else { + reject(new Error('validateDateAction call error')); + } + })); + }); + }; + + return validateDate; +}; diff --git a/src/utils/errorUtil.js b/src/utils/errorUtil.js new file mode 100644 index 000000000..159561334 --- /dev/null +++ b/src/utils/errorUtil.js @@ -0,0 +1,32 @@ +// Function to handle dynamic error messages +const getErrorMessage = (data, format = 'default') => { + let message = ''; + + Object.keys(data).forEach(key => { + const value = data[key]; + + // Check if the value is an array + if (Array.isArray(value)) { + // Convert array to a string based on the format + if (format === 'date') { + const dateMessage = value.find(msg => msg.includes("Ensimmäinen mahdollinen päivä on")); + if (dateMessage) { + // Extract the date + const date = dateMessage.split(' ').slice(-1)[0]; + message += `${key}: ${date}\n`; + } + } else { + message += `${key}: ${value.join(' ')}\n`; + } + } else { + // If not an array, just append the string or other type of value + message += `${key}: ${value}\n`; + } + }); + + return message; +}; + +export default { + getErrorMessage +}; diff --git a/src/utils/objectUtil.js b/src/utils/objectUtil.js new file mode 100644 index 000000000..c993f2ed2 --- /dev/null +++ b/src/utils/objectUtil.js @@ -0,0 +1,626 @@ +import timeUtil from "./timeUtil"; +//Phase main start and end value order should always be the same +const order = [ + 'projektin_kaynnistys_pvm', + 'kaynnistys_paattyy_pvm', + 'periaatteetvaihe_alkaa_pvm', + 'periaatteetvaihe_paattyy_pvm', + 'oasvaihe_alkaa_pvm', + 'oasvaihe_paattyy_pvm', + 'luonnosvaihe_alkaa_pvm', + 'luonnosvaihe_paattyy_pvm', + 'ehdotusvaihe_alkaa_pvm', + 'ehdotusvaihe_paattyy_pvm', + 'tarkistettuehdotusvaihe_alkaa_pvm', + 'tarkistettuehdotusvaihe_paattyy_pvm', + 'hyvaksyminenvaihe_alkaa_pvm', + 'hyvaksyminenvaihe_paattyy_pvm', + 'voimaantulovaihe_alkaa_pvm', + 'voimaantulovaihe_paattyy_pvm' +]; + +const getHighestNumberedObject = (obj1, arr) => { + // Helper function to extract the number from a content string + const extractNumber = str => { + // Find the last digit in the string + let i = str.length - 1; + while (i >= 0 && !/\d/.test(str[i])) { + i--; + } + // Extract the number + let numStr = ''; + while (i >= 0 && /\d/.test(str[i])) { + numStr = str[i] + numStr; + i--; + } + return numStr ? parseInt(numStr, 10) : -Infinity; // Return -Infinity if no number is found + }; + + // If no objects exist in the array, return null + if (arr.length === 0) return null; + + // If 'asd_x' objects exist, find the one with the highest number + if (obj1.length > 0) { + return obj1.reduce((maxObj, currentObj) => + extractNumber(currentObj.content) > extractNumber(maxObj.content) ? currentObj : maxObj + ); + } + + // Return null if no valid objects are found + return null; +}; + + const getMinObject = (latestObject) => { + // Iterate over the keys of the object + for (let key in latestObject) { + // Check if the value is an array + if (Array.isArray(latestObject[key]) && latestObject[key].length > 0) { + // Access the first object in the array + let firstObject = latestObject[key][0]; + return firstObject.name + } + } + return null; + } + + // Function to extract the number after the last underscore and return the object with the larger number + const getNumberFromString = (arr) => { + let largestObject = null; + let largestNumber = -Infinity; + + arr.forEach(obj => { + const match = obj.attributegroup.match(/_(\d+)$/); // Match digits after the last underscore + if (match) { + const number = parseInt(match[1], 10); // Get the number + if (number > largestNumber) { // Compare with the current largest number + largestNumber = number; + largestObject = obj; + } + } + }); + + return largestObject; // Return the object with the largest number + } + + const findValuesWithStrings = (arr, str1, str2, str3, str4) => { + let arrOfObj = arr.filter(obj => obj.name.includes(str1) && obj.name.includes(str2) && obj.name.includes(str3) && obj.name.includes(str4)); + // Get the object with the largest number from the array + const largest = getNumberFromString(arrOfObj); + return largest + }; + + const findLargestSuffix = (object,suffix) => { + // Ensure input is a valid object + if (typeof object !== 'object' || object === null || Object.keys(object).length === 0) { + return false; + } + + let maxNumber = -1; // Track the largest number + let resultKey = null; // Store the key with the largest suffix + + // Fetch all keys in the object + const keys = Object.keys(object); + + // Iterate over all the keys + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + // Only match keys starting with "milloin_oas_esillaolo_paattyy" and "milloin_oas_esillaolo_paattyy_2" for example + const match = key.match(suffix); + + if (match) { + // If there's a number, parse it, otherwise assume 0 + const number = match[1] ? parseInt(match[1], 10) : 0; + + // If the extracted number is larger, update maxNumber and resultKey + if (number > maxNumber) { + maxNumber = number; + resultKey = key; + } + } + } + // If nothing found at all, return false + return resultKey !== null ? object[resultKey] : false; + } + + const getPreviousObjectByName = (arr, id) => { + // Find the index of the object where object.name === name + const index = arr.findIndex(obj => obj.id === id); + + // If the index is greater than 0, return the object at one index earlier + if (index > 0) { + return arr[index - 1]; + } + + // Return null or undefined if it's the first index or not found + return null; + } + + const getObjectByName = (arr, id) => { + // Find the index of the object where object.name === name + const index = arr.findIndex(obj => obj.id === id); + + // return the object + if (index) { + return arr[index]; + } + + // Return null or undefined if it's the first index or not found + return null; + } + //Make these one and the same and add parameter for what type find is + const getPreviousObjectByGroup = (arr, deadlinegroup) => { + // Find the index of the object where object.name === name + const index = arr.findIndex(obj => obj.deadlinegroup === deadlinegroup); + + // If the index is greater than 0, return the object at one index earlier + if (index > 0) { + return arr[index - 1]; + } + + // Return null or undefined if it's the first index or not found + return null; + } + + const generateDateStringArray = (updatedAttributeData) => { + const updateAttributeArray = []; + + // Process only the keys with date strings + Object.keys(updatedAttributeData) + .filter(key => timeUtil.isDate(updatedAttributeData[key])) // Filter only date keys + .map(key => ({ key, date: new Date(updatedAttributeData[key]), value: updatedAttributeData[key] })) // Map keys to real Date objects and values + .forEach(item => { + updateAttributeArray.push({ key: item.key, value: item.value }); // Push each sorted key-value pair into the array + }); + + return updateAttributeArray + } + + const compareAndUpdateArrays = (arr1, arr2, deadlineSections) => { + let changes = []; + // Convert arr2 to a map for easier lookups + const map2 = new Map(arr2.map(item => [item.key, item.value])); + + // Iterate through arr1 and update values if a matching key is found in arr2 + for (let i = 0; i < arr1.length; i++) { + const key = arr1[i].key; + const value1 = arr1[i].value; + + if (map2.has(key)) { + const value2 = map2.get(key); + + // If values differ, update the value in arr1 and record the change + if (value1 !== value2) { + changes.push({ + key: key, + oldValue: value1, + newValue: value2 + }); + arr1[i].value = value2; // Update the value in arr1 + } + } + } + + // Check for keys in arr2 that are missing in arr1 + for (let [key, value2] of map2) { + if (!arr1.find(item => item.key === key)) { + changes.push({ + key: key, + oldValue: 'Not found in first array', + newValue: value2 + }); + // Optionally, add the missing key-value pair to arr1 + arr1.push({ key: key, value: value2 }); + } + } + // Adding distance_from_previous and distance_to_next to arr1 from deadlineSections + for (let i = 0; i < arr1.length; i++) { + const arr1Key = arr1[i].key; + + // Iterate over each section in deadlineSections + for (let section of deadlineSections) { + // Iterate over each attribute in section's attributes array + for (let sec of section.sections) { + for (let attribute of sec.attributes) { + if (attribute.name === arr1Key) { + // Found a match, now add distance_from_previous and distance_to_next + arr1[i].distance_from_previous = attribute?.distance_from_previous || null; + arr1[i].distance_to_next = attribute?.distance_to_next || null; + arr1[i].initial_distance = attribute?.initial_distance?.distance || null + arr1[i].date_type = attribute?.date_type ?? "arkipäivät"; + arr1[i].order = i; + break; // Exit the loop once the match is found + } + } + } + } + } + + // Extract the order of keys (names) from deadlineSections + //DeadlineSections has the correct order always + let keyOrder = []; + for (let section of deadlineSections) { + for (let sec of section.sections) { + for (let attribute of sec.attributes) { + keyOrder.push(attribute.name); // Get the order of names + } + } + } + + // Sort arr1 based on the keyOrder extracted from deadlineSections + arr1.sort((a, b) => { + const indexA = keyOrder.indexOf(a.key); + const indexB = keyOrder.indexOf(b.key); + + // If both keys exist in keyOrder, sort based on their index + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + // If only one key exists in keyOrder, prioritize that one + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + + // If neither key exists in keyOrder, maintain their original order + return 0; + }); + + //Sort phase start end data by order const + arr1 = sortPhaseData(arr1,order) + //Return in order array ready for comparing next and previous value distances + arr1 = arr1.filter(item => !item.key.includes("viimeistaan_lausunnot_")); //filter out has no next and prev values + return arr1 + } + //Sort by certain predetermined order + const sortPhaseData = (arr,order) => { + arr.sort((a, b) => { + // check for the 'order' property + const aHasOrder = Object.prototype.hasOwnProperty.call(a, 'order'); + const bHasOrder = Object.prototype.hasOwnProperty.call(b, 'order'); + + // If both items have 'order', keep their relative positions + if (aHasOrder && bHasOrder) { + return 0; // Maintain original order for these items + } + // If only one of them has 'order', prioritize that one to stay in place + if (aHasOrder) return -1; + if (bHasOrder) return 1; + + // Otherwise, sort based on the provided order array + return order.indexOf(a.key) - order.indexOf(b.key); + }); + + arr = increasePhaseValues(arr) + return arr + } + + const increasePhaseValues = (arr) => { + const filteredArr = arr.filter(item => order.includes(item.key)); + // Ensure each subsequent value is equal to or greater than the previous one + for (let i = 1; i < filteredArr.length; i++) { + if (filteredArr[i - 1].key.includes("paattyy_pvm") && filteredArr[i].key.includes("alkaa_pvm") || filteredArr[i].key.includes("kaynnistys_pvm")) { + // Convert values to Date objects for comparison + const previousValue = new Date(filteredArr[i - 1].value); + const currentValue = new Date(filteredArr[i].value); + + // Adjust the current value if it's less than the previous value + if (currentValue < previousValue) { + filteredArr[i].value = filteredArr[i - 1].value; + } + } + } + // Replace the original elements in arr with updated elements from filteredArr + const result = arr.map(item => { + const updatedItem = filteredArr.find(filteredItem => filteredItem.key === item.key); + return updatedItem ? updatedItem : item; + }); + return result + } + + const checkForDecreasingValues = (arr,isAdd,field,disabledDates,oldDate,movedDate,moveToPast,projectSize) => { + // Find the index of the next item where dates should start being pushed + const currentIndex = arr.findIndex(item => item.key === field); + let indexToContinue = 0 + // If adding items + if (isAdd) { + // Move the nextItem and all following items forward if item minium is exceeded + for (let i = currentIndex; i < arr.length; i++) { + if(!arr[i].key.includes("voimaantulo_pvm") && !arr[i].key.includes("rauennut") && !arr[i].key.includes("kumottu_pvm") && !arr[i].key.includes("tullut_osittain_voimaan_pvm") + && !arr[i].key.includes("valtuusto_poytakirja_nahtavilla_pvm") && !arr[i].key.includes("hyvaksymispaatos_valitusaika_paattyy") && !arr[i].key.includes("valtuusto_hyvaksymiskuulutus_pvm") + && !arr[i].key.includes("hyvaksymispaatos_pvm")){ + let newDate = new Date(arr[i].value); + //At the moment some previous values are falsely null for some reason, can be remove when is fixed on backend and Excel. + //Get minium gap for two dates next to each other that are moved + const miniumGap = arr[i].initial_distance === null ? arr[i].key.includes("lautakunnassa") ? 22 : 5 : arr[i].initial_distance + if(arr[i - 1].key.includes("paattyy") && arr[i].key.includes("mielipiteet") || arr[i - 1].key.includes("paattyy") && arr[i].key.includes("lausunnot")){ + //mielipiteet and paattyy is always the same value + newDate = new Date(arr[i - 1].value); + } + else{ + //Calculate difference between two dates and rule out holidays and set on date type specific allowed dates and keep minium gaps + newDate = arr[i]?.date_type ? timeUtil.dateDifference(arr[i].key,arr[i - 1].value,arr[i].value,disabledDates?.date_types[arr[i]?.date_type]?.dates,disabledDates?.date_types?.disabled_dates?.dates,miniumGap,projectSize,true) : newDate + } + // Update the array with the new date + newDate.setDate(newDate.getDate()); + arr[i].value = newDate.toISOString().split('T')[0]; + //Move phase start and end dates + if(arr[i].distance_from_previous === undefined && arr[i].key.endsWith('_pvm') && arr[i].key.includes("_paattyy_")){ + const targetSubstring = arr[i].key.split('vaihe')[0]; + // Iterate backwards from the given index + const res = reverseIterateArray(arr,i,targetSubstring) + const differenceInTime = new Date(res) - new Date(arr[i].value) + const differenceInDays = differenceInTime / (1000 * 60 * 60 * 24); + if(differenceInDays >= 5){ + arr[i].value = res + if(arr[i]?.key?.includes("tarkistettuehdotusvaihe_paattyy_pvm")){ + //Move hyvaksyminenvaihe_paattyy_pvm and voimaantulovaihe_paattyy_pvm as many days as tarkistettuehdotusvaihe_paattyy_pvm + const items = arr.filter(el => el.key?.includes("hyvaksyminenvaihe_paattyy_pvm") || el.key?.includes("voimaantulovaihe_paattyy_pvm")); + if (items) { + items.forEach(item => { + const currentDate = new Date(item.value); + currentDate.setDate(currentDate.getDate() + differenceInDays); + item.value = currentDate.toISOString().split('T')[0]; + }); + } + } + } + } + } + } + } + else if(currentIndex !== -1){ + for (let i = currentIndex; i < arr.length; i++) { + if(!arr[i].key.includes("voimaantulo_pvm") && !arr[i].key.includes("rauennut") && !arr[i].key.includes("kumottu_pvm") && !arr[i].key.includes("tullut_osittain_voimaan_pvm") + && !arr[i].key.includes("valtuusto_poytakirja_nahtavilla_pvm") && !arr[i].key.includes("hyvaksymispaatos_valitusaika_paattyy") && !arr[i].key.includes("valtuusto_hyvaksymiskuulutus_pvm") + && !arr[i].key.includes("hyvaksymispaatos_pvm")){ + let newDate = new Date(arr[i].value); + + if(arr[i - 1].key.includes("paattyy") && arr[i].key.includes("mielipiteet")){ + //mielipiteet and paattyy is always the same value + newDate = new Date(arr[i - 1].value); + } + else{ + //Paattyy and nahtavillaolo l-xl are independent of other values + if( + ((projectSize === "XS" || projectSize === "S" || projectSize === "M") && i === currentIndex) || + ((projectSize === "XL" || projectSize === "L") && i === currentIndex) + ){ + //Make next or previous or previous and 1 after previous dates follow the moved date if needed + if(arr[currentIndex]?.key?.includes("kylk_maaraaika") || arr[currentIndex]?.key?.includes("kylk_aineiston_maaraaika") || arr[currentIndex]?.key?.includes("_lautakunta_aineiston_maaraaika")){ + //maaraika in lautakunta moving + const lautakuntaResult = timeUtil.findAllowedLautakuntaDate(movedDate, arr[i + 1].initial_distance, disabledDates?.date_types[arr[i + 1]?.date_type]?.dates, false, disabledDates?.date_types[arr[i]?.date_type]?.dates); + arr[i + 1].value = new Date(lautakuntaResult).toISOString().split('T')[0]; + indexToContinue = i + 1 + } + else if(arr[currentIndex]?.key?.includes("paattyy") || ( (projectSize === "XL" || projectSize === "L") && (arr[currentIndex]?.key.includes("nahtavilla_alkaa") || arr[currentIndex]?.key.includes("nahtavilla_paattyy")) ) ){ + newDate = new Date(arr[i].value); + indexToContinue = i + } + else if(arr[currentIndex]?.key?.includes("lautakunnassa") && !arr[currentIndex]?.key?.includes("lautakunnassa_") || arr[currentIndex]?.key?.includes("alkaa")){ + //lautakunta and alkaa values + const maaraaikaResult = timeUtil.findAllowedDate(movedDate, arr[i].initial_distance, disabledDates?.date_types[arr[i -1]?.date_type]?.dates, true); + arr[i - 1].value = new Date(maaraaikaResult).toISOString().split('T')[0]; + indexToContinue = i + } + else if(arr[currentIndex]?.key?.includes("maaraaika")){ + //Maaraiaka moving + const alkaaResult = timeUtil.findAllowedDate(movedDate, arr[i + 1].initial_distance, disabledDates?.date_types[arr[i]?.date_type]?.dates, false); + arr[i + 1].value = new Date(alkaaResult).toISOString().split('T')[0]; + indexToContinue = i + 1 + if(!arr[currentIndex]?.key?.includes("kylk_maaraaika") && !arr[currentIndex]?.key?.includes("kylk_aineiston_maaraaika") && !arr[currentIndex]?.key?.includes("_lautakunta_aineiston_maaraaika") && !arr[currentIndex]?.key?.includes("lautakunnassa") && arr[currentIndex]?.key?.includes("maaraaika")){ + const paattyyResult = timeUtil.findAllowedDate(alkaaResult, arr[i + 2].initial_distance, disabledDates?.date_types[arr[i +2]?.date_type]?.dates, false); + //When moving maaraaika in esillaolo or nahtavillaolo not in lautakunta + arr[i + 2].value = new Date(paattyyResult).toISOString().split('T')[0]; + indexToContinue = i + 2 + } + } + } + else{ + if(!moveToPast && i > indexToContinue){ + const miniumGap = arr[i].initial_distance === null ? arr[i].key.includes("lautakunnassa") ? 22 : 5 : arr[i].initial_distance + //Calculate difference between two dates and rule out holidays and set on date type specific allowed dates and keep minium gaps + newDate = arr[i]?.date_type ? timeUtil.dateDifference(arr[i].key,arr[i - 1].value,arr[i].value,disabledDates?.date_types[arr[i]?.date_type]?.dates,disabledDates?.date_types?.disabled_dates?.dates,miniumGap,projectSize,false) : newDate + newDate = new Date(newDate) + } + } + } + // Update the array with the new date + newDate.setDate(newDate.getDate()); + arr[i].value = newDate.toISOString().split('T')[0]; + //Move phase start and end dates + if(arr[i].distance_from_previous === undefined && arr[i].key.endsWith('_pvm') && arr[i].key.includes("_paattyy_") + && !arr[i].key.includes("voimaantulo_pvm") && !arr[i].key.includes("rauennut") && !arr[i].key.includes("kumottu_pvm") && !arr[i].key.includes("tullut_osittain_voimaan_pvm")){ + const targetSubstring = arr[i].key.split('vaihe')[0]; + // Iterate backwards from the given index + const res = reverseIterateArray(arr,i,targetSubstring) + const differenceInTime = new Date(res) - new Date(arr[i].value) + const differenceInDays = differenceInTime / (1000 * 60 * 60 * 24); + if(differenceInDays >= 5){ + arr[i].value = res + if(arr[i]?.key?.includes("tarkistettuehdotusvaihe_paattyy_pvm")){ + //Move hyvaksyminenvaihe_paattyy_pvm and voimaantulovaihe_paattyy_pvm as many days as tarkistettuehdotusvaihe_paattyy_pvm + const items = arr.filter(el => el.key?.includes("hyvaksyminenvaihe_paattyy_pvm") || el.key?.includes("voimaantulovaihe_paattyy_pvm")); + if (items) { + items.forEach(item => { + const currentDate = new Date(item.value); + currentDate.setDate(currentDate.getDate() + differenceInDays); + item.value = currentDate.toISOString().split('T')[0]; + }); + } + } + } + } + } + } + } + sortPhaseData(arr,order) + return arr + } + + const reverseIterateArray = (arr,index,target) => { + let targetString = target + if(target === "tarkistettuehdotus"){ + //other values in array at tarkistettu ehdotus phase are with _ but phase values are without + targetString = "tarkistettu_ehdotus" + } + else if(target === "ehdotus"){ + targetString = ["ehdotuksen", "kaavaehdotus", "ehdotus"] + } + for (let i = index - 1; arr.length >= 0 && i >= 0; i--) { + // Check if 'distance_from_previous' attribute does not exist and if the key contains the target substring + if(target === "ehdotus"){ + for (let j = 0; j < targetString.length; j++) { + if (!arr[i].key.includes('tarkistettu_ehdotus') && !arr[i].key.endsWith('_pvm') && arr[i].key.includes(targetString[j])) { + return arr[i].value; + } + } + } + else if (arr[i].key.includes(targetString) && !arr[i].key.endsWith('_pvm')) { + return arr[i].value; + } + } + return null; // Return null if no such key is found + } + + // Function to update original object by comparing keys + const updateOriginalObject = (originalObj, updatedArr) => { + updatedArr.forEach(item => { + if (Object.prototype.hasOwnProperty.call(originalObj, item.key)) { + originalObj[item.key] = item.value; // Update value if key exists + } + }); + return originalObj; + } + + // Helper function to compare values + const compareObjectValues = (key, value1, value2) => { + if (typeof value1 === 'object' && typeof value2 === 'object') { + return findDifferencesInObjects(value1, value2).map(diff => ({ + key: `${key}.${diff.key}`, // Nesting the key to show hierarchy + obj1: diff.obj1, + obj2: diff.obj2 + })); // Recursively compare if both are objects + } else if (value1 !== value2) { + return [{ key, obj1: value1, obj2: value2 }]; // Return an array of differences + } + return []; // No difference + } + // compare 2 objects and get differences and return them in array + const findDifferencesInObjects = (obj1, obj2) => { + let differences = []; + + // Compare properties of obj1 and obj2 + for (let key in obj1) { + if (Object.hasOwn(obj1, key)) { + const diff = compareObjectValues(key, obj1[key], obj2[key]); + differences = [...differences, ...diff]; + } + } + // Check for properties that are in obj2 but not in obj1 + for (let key in obj2) { + if (Object.hasOwn(obj2, key) && !(key in obj1)) { + differences.push({ key, obj1: undefined, obj2: obj2[key] }); + } + } + + return differences; + } + // Function to find the item for example where item.name === inputName + const findMatchingName = (array, inputName, key) => { + return array.find(item => item[key] === inputName); + }; + // Function to find the item before the one for example where item.name === inputName + const findItem = (array, inputName, key, direction) => { + //if direction is 1 then find next item or -1 for previous + const index = array.findIndex(item => item[key] === inputName); + // If index is valid and direction is either 1 (next) or -1 (previous) + if (index !== -1) { + const newIndex = index + direction; + // Ensure the new index is within bounds of the array + if (newIndex >= 0 && newIndex < array.length) { + return array[newIndex]; // Return the next or previous item based on direction + } + } + + return null; // Return null if no next or previous item is found + }; + + const filterHiddenKeys = (updatedAttributeData) => { + //Remove all keys that are still hidden in vistimeline so they are not moved in data and later saved + const phaseNames = [ + "periaatteet","oas","luonnos","kaavaehdotus","ehdotus","ehdotuksesta","ehdotuksen","tarkistettu_ehdotus" + ]; + const lautakunnat = ["periaatteet_lautakuntaan_2","periaatteet_lautakuntaan_3","periaatteet_lautakuntaan_4", + "kaavaluonnos_lautakuntaan_2","kaavaluonnos_lautakuntaan_3","kaavaluonnos_lautakuntaan_4", + "kaavaehdotus_lautakuntaan_2","kaavaehdotus_lautakuntaan_3","kaavaehdotus_lautakuntaan_4", + "tarkistettu_ehdotus_lautakuntaan_2","tarkistettu_ehdotus_lautakuntaan_3","tarkistettu_ehdotus_lautakuntaan_4" + ]; + const esillaolot = ["jarjestetaan_periaatteet_esillaolo_2","jarjestetaan_periaatteet_esillaolo_3", + "jarjestetaan_oas_esillaolo_2","jarjestetaan_oas_esillaolo_3","jarjestetaan_luonnos_esillaolo_2","jarjestetaan_luonnos_esillaolo_3", + "kaavaehdotus_uudelleen_nahtaville_2","kaavaehdotus_uudelleen_nahtaville_3","kaavaehdotus_uudelleen_nahtaville_4" + ]; + // Phase mapping for "kaavaehdotus" and its variations + const phaseGroup = { + "kaavaehdotus": ["kaavaehdotus", "ehdotus", "ehdotuksesta", "ehdotuksen"] + }; + //find index keys that exist in data + const presentLautakunnat = lautakunnat.filter(key => key in updatedAttributeData); + const presentEsillaolot = esillaolot.filter(key => key in updatedAttributeData); + + //find index and phase from presentLautakunnat and presentEsillaolot + const lautakunnatPhases = presentLautakunnat.map(key => { + const phase = phaseNames.find(phaseName => key.includes(phaseName)); + const number = key.match(/_(\d+)/) ? key.match(/_(\d+)/)[1] : null; + return { phase: phaseGroup[phase] || [phase], number }; // Ensure phase is always an array + }); + + const esillaolotPhases = presentEsillaolot.map(key => { + let phase = phaseNames.find(phaseName => key.includes(phaseName)); + const number = key.match(/_(\d+)/) ? key.match(/_(\d+)/)[1] : null; + // If phase is "kaavaehdotus", replace it with all related phases + if (phase === "kaavaehdotus") { + phase = phaseGroup["kaavaehdotus"]; + } else { + phase = [phase]; // Keep it as an array for consistency + } + return { phase, number }; + }); + + //filter all but index keys from data + return Object.entries(updatedAttributeData).reduce((acc, [key, value]) => { + const indexMatch = key.match(/_(\d+)/); + const index = indexMatch ? parseInt(indexMatch[1], 10) : null; + //const isLautakunnatPhase = lautakunnatPhases.some(phase => key.includes(phase.phase) && key.includes(phase.number)); + //const isEsillaolotPhase = esillaolotPhases.some(phase => key.includes(phase.phase) && key.includes(phase.number)); + const isLautakunnatPhase = lautakunnatPhases.some( + phaseObj => phaseObj.phase.some(p => key.includes(p)) && key.endsWith(`_${phaseObj.number}`) + ); + + const isEsillaolotPhase = esillaolotPhases.some( + phaseObj => phaseObj.phase.some(p => key.includes(p)) && key.endsWith(`_${phaseObj.number}`) + ); + if (index === null || index === 1 || (isLautakunnatPhase || isEsillaolotPhase) ) { + acc[key] = value; + } + return acc; + }, {}); + } + +export default { + getHighestNumberedObject, + getMinObject, + findValuesWithStrings, + findLargestSuffix, + getPreviousObjectByName, + getObjectByName, + getPreviousObjectByGroup, + compareAndUpdateArrays, + checkForDecreasingValues, + generateDateStringArray, + updateOriginalObject, + findDifferencesInObjects, + compareObjectValues, + findMatchingName, + findItem, + filterHiddenKeys +} \ No newline at end of file diff --git a/src/utils/projectAutofillUtils.js b/src/utils/projectAutofillUtils.js index 2a8f5ff9d..6a42f7d89 100644 --- a/src/utils/projectAutofillUtils.js +++ b/src/utils/projectAutofillUtils.js @@ -224,6 +224,10 @@ export const getFieldAutofillValue = ( formExtraValue = extraVariables ? projectUtils.findValueFromObject(formValues, extraVariables[0]) : '' + + if (formExtraValue?.ops && formValue?.ops) { + formExtraValue = toPlaintext(formValue.ops).trim() + } } // List rule @@ -279,7 +283,12 @@ export const getFieldAutofillValue = ( } else { if (!projectNameAdded) { if (thenBranch && thenBranch !== '') { - returnValue = `${formExtraValue} ${thenBranch}` + if (callerFormName === EDIT_PROJECT_TIMETABLE_FORM){ + // Dirty hack, but so is the rest of this entire file + returnValue = `${formExtraValue}` + } else { + returnValue = `${formExtraValue} ${thenBranch}` + } } else { returnValue = formExtraValue } diff --git a/src/utils/projectVisibilityUtils.js b/src/utils/projectVisibilityUtils.js index c13727066..ac8fe479b 100644 --- a/src/utils/projectVisibilityUtils.js +++ b/src/utils/projectVisibilityUtils.js @@ -58,11 +58,9 @@ export const showField = (field, formValues, currentName) => { if (!hasTrue) { returnValue = true } - } else if ( - field && - field.visibility_conditions && - field.visibility_conditions.length > 0 - ) { + } + else if (field && field.visibility_conditions && field.visibility_conditions.length > 0) + { field.visibility_conditions.forEach(visibilityCondition => { const { variable } = visibilityCondition const { operator } = visibilityCondition @@ -115,8 +113,50 @@ export const showField = (field, formValues, currentName) => { } } }) - } else { + } + else { returnValue = true } + return returnValue } + + +// Gets the name of the attribute used to control deadline group visiblity +export const getVisibilityBoolName = (deadlineGroup) => { + const vis_bool_map = { + 'kaynnistys_1': null, + 'periaatteet_esillaolokerta_1': 'jarjestetaan_periaatteet_esillaolo_1', + 'periaatteet_esillaolokerta_2': 'jarjestetaan_periaatteet_esillaolo_2', + 'periaatteet_esillaolokerta_3': 'jarjestetaan_periaatteet_esillaolo_3', + 'periaatteet_lautakuntakerta_1': 'periaatteet_lautakuntaan_1', + 'periaatteet_lautakuntakerta_2': 'periaatteet_lautakuntaan_2', + 'periaatteet_lautakuntakerta_3': 'periaatteet_lautakuntaan_3', + 'periaatteet_lautakuntakerta_4': 'periaatteet_lautakuntaan_4', + 'oas_esillaolokerta_1': 'jarjestetaan_oas_esillaolo_1', + 'oas_esillaolokerta_2': 'jarjestetaan_oas_esillaolo_2', + 'oas_esillaolokerta_3': 'jarjestetaan_oas_esillaolo_3', + 'luonnos_esillaolokerta_1': 'jarjestetaan_luonnos_esillaolo_1', + 'luonnos_esillaolokerta_2': 'jarjestetaan_luonnos_esillaolo_2', + 'luonnos_esillaolokerta_3': 'jarjestetaan_luonnos_esillaolo_3', + 'luonnos_lautakuntakerta_1': 'kaavaluonnos_lautakuntaan_1', + 'luonnos_lautakuntakerta_2': 'kaavaluonnos_lautakuntaan_2', + 'luonnos_lautakuntakerta_3': 'kaavaluonnos_lautakuntaan_3', + 'luonnos_lautakuntakerta_4': 'kaavaluonnos_lautakuntaan_4', + 'ehdotus_nahtavillaolokerta_1': 'kaavaehdotus_nahtaville_1', + 'ehdotus_nahtavillaolokerta_2': 'kaavaehdotus_uudelleen_nahtaville_2', + 'ehdotus_nahtavillaolokerta_3': 'kaavaehdotus_uudelleen_nahtaville_3', + 'ehdotus_nahtavillaolokerta_4': 'kaavaehdotus_uudelleen_nahtaville_4', + 'ehdotus_lautakuntakerta_1': 'kaavaehdotus_lautakuntaan_1', + 'ehdotus_lautakuntakerta_2': 'kaavaehdotus_lautakuntaan_2', + 'ehdotus_lautakuntakerta_3': 'kaavaehdotus_lautakuntaan_3', + 'ehdotus_lautakuntakerta_4': 'kaavaehdotus_lautakuntaan_4', + 'tarkistettu_ehdotus_lautakuntakerta_1': 'tarkistettu_ehdotus_lautakuntaan_1', + 'tarkistettu_ehdotus_lautakuntakerta_2': 'tarkistettu_ehdotus_lautakuntaan_2', + 'tarkistettu_ehdotus_lautakuntakerta_3': 'tarkistettu_ehdotus_lautakuntaan_3', + 'tarkistettu_ehdotus_lautakuntakerta_4': 'tarkistettu_ehdotus_lautakuntaan_4', + 'hyvaksyminen_1': null, + 'voimaantulo_1': null + } + return vis_bool_map[deadlineGroup] || null; +}; diff --git a/src/utils/textUtil.js b/src/utils/textUtil.js new file mode 100644 index 000000000..6b3a00287 --- /dev/null +++ b/src/utils/textUtil.js @@ -0,0 +1,89 @@ +const replaceScandics = (str) => { + const scandicMap = { + 'å': 'a', + 'ä': 'a', + 'ö': 'o', + 'ø': 'o', + 'Å': 'A', + 'Ä': 'A', + 'Ö': 'O', + 'Ø': 'O', + 'æ': 'ae', + 'Æ': 'AE', + 'ð': 'd', + 'Ð': 'D', + 'þ': 'th', + 'Þ': 'Th' + }; + + return str.replace(/[åäöøÅÄÖØæÆðÐþÞ]/g, function(match) { + return scandicMap[match] || match; + }); +} + +const capitalizeAndRemoveUnderscores = (str) => { + // Replace all underscores with spaces + let formattedStr = str.replace(/_/g, ' '); + // Trim leading and trailing spaces just in case + formattedStr = formattedStr.trim(); + // Capitalize the first character and concatenate with the rest of the string + formattedStr = formattedStr.charAt(0).toUpperCase() + formattedStr.slice(1); + + return formattedStr; +} +//Replaces string patterns with another +const replacePattern = (key,patternToReplace,replaceablePattern) => { + let newKey = key.replace(patternToReplace, replaceablePattern); + if (newKey.includes("milloin_ehdotuksen_nahtavilla_paattyy_iso")) { + // Remove '_iso' part + newKey = newKey.replace("_iso", ""); + } + if(newKey.includes("milloin_ehdotuksen_nahtavilla_paattyy_pieni")){ + // Remove '_pieni' part + newKey = newKey.replace("_pieni", ""); + } + return newKey; +} + +// Return the first matching substring +const getFirstMatchingSubstring = (str, substrings) => { + const lowerCaseStr = str.toLowerCase(); + return substrings.find(substring => lowerCaseStr.includes(substring.toLowerCase())) || null; +}; + +const modifyLastNumberInString = (str) => { + // Regular expression to check if the string ends with an underscore followed by a number + const regex = /_(\d+)$/; + + // Check if the string matches the pattern + const match = str.match(regex); + + if (match) { + // If there's a match, increment the number and return the modified string + let number = parseInt(match[1], 10); // Extract and convert the number part + return str.replace(regex, `_${number + 1}`); // Replace the number with the incremented one + } else { + // If no match, append "_2" to the string + return `${str}_2`; + } +} + +const getNumberAfterSuffix = (str) => { + const regex = /_(\d+)$/; // Regex to match underscore followed by a number at the end of the string + const match = str.match(regex); + if (match) { + const number = match[1]; // Extracted number + return number + } + //if nothing found return null + return null; +} + +export default { + replaceScandics, + capitalizeAndRemoveUnderscores, + replacePattern, + getFirstMatchingSubstring, + modifyLastNumberInString, + getNumberAfterSuffix +} \ No newline at end of file diff --git a/src/utils/timeUtil.js b/src/utils/timeUtil.js new file mode 100644 index 000000000..6aaad17f8 --- /dev/null +++ b/src/utils/timeUtil.js @@ -0,0 +1,755 @@ + import objectUtil from "./objectUtil"; + + const isWeekend = (date) => { + const day = new Date(date).getDay(); + return day === 0 || day === 6; // 0 = Sunday, 6 = Saturday + }; + + const getHighestDate = (attributeValues) => { + const datesToCompare = ["tullut_osittain_voimaan_pvm", "voimaantulo_pvm", "kumottu_pvm", "rauennut"] + .map(dateField => attributeValues[dateField]) + .filter(date => date) + .map(date => new Date(date)); + let highestDate = datesToCompare.length ? new Date(Math.max(...datesToCompare)) : null; + if (highestDate) { + highestDate = formatDate(highestDate,false,false); + } + return highestDate + } + + // Helper function to format a Date object to "YYYY-MM-DD" + const formatDate = (date,addDay,addDayNumber) => { + if(addDay){ + date.setDate(date.getDate() + addDayNumber); + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Month is 0-based + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + // Helper function to check if a date is a holiday + const isHoliday = (date,isInFilter,holidays) => { + const dateStr = date.toISOString().split('T')[0]; // Convert to 'YYYY-MM-DD' format + return isInFilter ? holidays.includes(dateStr) : !holidays.includes(dateStr); + } + + // Function to get the date x weekdays earlier, excluding holidays + const getPastDate = (startDate, validDaysToSubtract, isInFilter, excludedDays) => { + let currentDate = new Date(startDate); // Start from the original date + let subtractedDays = 0; + + // Loop until we have subtracted the required number of valid days + while (subtractedDays < validDaysToSubtract) { + currentDate.setDate(currentDate.getDate() - 1); // Move to the previous day + + // Check if the current date is not a weekend and not a holiday + if (!isWeekend(currentDate) && !isHoliday(currentDate,isInFilter,excludedDays)) { + subtractedDays++; // Increment valid days counter + } + } + return currentDate; + } + + const calculateWeekdayDifference = (startDate, endDate) => { + let end = new Date(endDate); + let currentDate = new Date(startDate); + let daysDifference = 1; + let calculate = true + + // Loop from start date to end date + while (calculate) { + // Check if it's a weekday (Monday to Friday) + if (!isWeekend(currentDate)) { + daysDifference++; + } + + // Move to the next day + currentDate.setDate(currentDate.getDate() + 1); + calculate = currentDate <= end + } + + return daysDifference; + } + + const normalizeDate = (date) => { + const normalizedDate = new Date(date); + normalizedDate.setUTCHours(0, 0, 0, 0); + return new Date(normalizedDate); + } + + const dateDifference = (cur, previousValue, currentValue, allowedDays, disabledDays, miniumGap, projectSize, addingNew) => { + let previousDate = normalizeDate(previousValue); + let currentDate = normalizeDate(currentValue); + let gap = miniumGap; + if (!addingNew) { + if (!cur.includes("_lautakunta_aineiston_maaraaika") && !cur.includes("kylk_aineiston_maaraaika") && cur.includes("maaraaika") || miniumGap >= 31) { + gap = 5; + } + } else if ((addingNew && (projectSize === 'M' || projectSize === 'S') && cur.includes("milloin_ehdotuksen_nahtavilla_paattyy"))) { + gap = 22; + } + + if (previousDate >= currentDate) { + currentDate = normalizeDate(previousDate); + currentDate.setDate(currentDate.getDate() + gap); + } + + let dateStr = currentDate.toISOString().split('T')[0]; + while (!allowedDays.includes(dateStr) || disabledDays.includes(dateStr) || calculateWeekdayDifference(previousDate, currentDate) < gap) { + currentDate.setDate(currentDate.getDate() + 1); + dateStr = currentDate.toISOString().split('T')[0]; + } + + let calendarDays = 0; + let tempDate = normalizeDate(previousDate); + if (previousDate < currentDate) { + while (tempDate < new Date(currentDate)) { + if (isWorkingDay(tempDate, allowedDays, disabledDays)) { + calendarDays++; + } + tempDate.setDate(tempDate.getDate() + 1); + } + } + + if (calendarDays < gap) { + while (calendarDays <= gap) { + if (isWorkingDay(currentDate, allowedDays, disabledDays)) { + calendarDays++; + } + currentDate.setDate(currentDate.getDate() + 1); + } + if (cur.includes("lautakunnassa")) { + while (currentDate.getDay() !== 2 || !isWorkingDay(currentDate, allowedDays, disabledDays)) { + currentDate.setDate(currentDate.getDate() + 1); + } + } + } + + // Convert currentDate to the same format as the dates in the allowedDays array + const formattedNewDate = currentDate.toISOString().split('T')[0]; + // Check that date is inside the allowedDays array + if (!allowedDays.includes(formattedNewDate)) { + // Find the next possible date from allowedDays because the date was not allowed + const nextPossibleDate = allowedDays.find(date => new Date(date) > currentDate); + if (nextPossibleDate) { + currentDate = new Date(nextPossibleDate); + } + } + + return normalizeDate(currentDate); + }; + + const isWorkingDay = (date, allowedDays, holidays) => { + const day = date.getDay(); + const formattedDate = formatDate(date); + if (allowedDays.includes(formattedDate)) { + return true; + } + return day !== 0 && day !== 6 && !holidays.includes(formattedDate); + }; + + const findAllowedLautakuntaDate = (newDate, miniumGap, allowedDays, moveToPast, maaraaikaAllowedDates) => { + const gap = miniumGap; + // Check for direct match in maaraaikaAllowedDates + let maaraaikaMatch = maaraaikaAllowedDates.find(date => date === newDate); + let maaraaikaDate; + if (maaraaikaMatch) { + const closestIndex = maaraaikaAllowedDates.indexOf(maaraaikaMatch); + maaraaikaDate = moveToPast ? maaraaikaAllowedDates[closestIndex - gap] : maaraaikaAllowedDates[closestIndex + gap]; + } + else{ + // Find the closest date from maaraaikaAllowedDates considering the miniumGap + let closestDate = null; + let smallestDiff = Infinity; + + maaraaikaAllowedDates.forEach(date => { + const diff = new Date(date) - new Date(newDate); + + if (diff >= 0 && diff < smallestDiff) { + smallestDiff = diff; + closestDate = date; + } + }); + + if (!closestDate) { + return null; // Return null if no closest date is found + } + + const closestIndex = maaraaikaAllowedDates.indexOf(closestDate); + maaraaikaDate = moveToPast ? maaraaikaAllowedDates[closestIndex - gap] : maaraaikaAllowedDates[closestIndex + gap]; + } + // Find the matching or closest date from allowedDays using the maaraaikaDate + let match = allowedDays.find(date => date === maaraaikaDate); + if (match) { + return match; + } + + // If no exact match is found, find the closest date from allowedDays + let closestDate = null; + let smallestDiff = Infinity; + + allowedDays.forEach(date => { + const diff = new Date(date) - new Date(maaraaikaDate); + + if (diff >= 0 && diff < smallestDiff) { + smallestDiff = diff; + closestDate = date; + } + }); + + return closestDate; // Return the closest date from allowedDays + }; + + const findAllowedDate = (newDate, miniumGap, allowedDays, moveToPast) => { + //Find newDate from allowedDays, add miniumGap to it and return the date, moveToPast is reverse iteration of array + + const gap = miniumGap; + // Check for direct match + let match = allowedDays.find(date => date === newDate); + if (match) { + const matchIndex = allowedDays.indexOf(match); + return moveToPast ? allowedDays[matchIndex - gap] : allowedDays[matchIndex + gap]; + } + + // Find the closest date if no exact match is found + let closestDate = null; + let smallestDiff = Infinity; + + allowedDays.forEach(date => { + const diff = new Date(date) - new Date(newDate); + + if (diff >= 0 && diff < smallestDiff) { + smallestDiff = diff; + closestDate = date; + } + }); + + if (closestDate) { + const closestIndex = allowedDays.indexOf(closestDate); + return moveToPast ? allowedDays[closestIndex - gap] : allowedDays[closestIndex + gap]; + } + + return null; // Return the date that meets the gap condition, or null if none + }; + + + // Function to adjust dates if the difference is less than or equal to 5 days + const adjustDates = (dataArray) => { + for (let i = 0; i < dataArray.length - 1; i++) { + const currentValue = dataArray[i].value; + const nextValue = dataArray[i + 1].value; + + // Calculate the difference in days between current and next date + let dateAndMin = dateDifference(currentValue, nextValue); + + if (dateAndMin <= 5) { + + // Add the difference to the next date + const nextDate = new Date(nextValue); + nextDate.setDate(nextDate.getDate() + dateAndMin); // Push next date forward by the difference + + // Update the next value in the array + dataArray[i + 1].value = nextDate.toISOString().split('T')[0]; // Convert back to YYYY-MM-DD format + + } + } + } + + // Function to add days to a date and return in "YYYY-MM-DD" format + const addDays = (type, date, days, disabledDates, excludeWeekends, origDate, allDisabledDates, initialDistance) => { + let newDate = new Date(date); + let originalDate = origDate ? new Date(origDate) : false; + let filter = ""; + let isInFilter = true; + let addDays = true; + let finalDateStr; + + const setFilterAndInFilter = (type) => { + if (["työpäivät", "esilläolopäivät", "arkipäivät", "lautakunta", "lautakunta_määräaika"].includes(type)) { + filter = disabledDates; + isInFilter = false; + } + }; + + const calculateActualDifference = (originalDate, tempDate, workdays) => { + let actualDifference = 0; + let calculate = true + while (calculate) { + if (excludeWeekends && (originalDate.getDay() === 0 || originalDate.getDay() === 6)) { + originalDate.setDate(originalDate.getDate() + 1); + } else if (!checkArrayForValue(workdays, originalDate)) { + originalDate.setDate(originalDate.getDate() + 1); + } else { + originalDate.setDate(originalDate.getDate() + 1); + actualDifference++; + } + calculate = originalDate <= tempDate + } + return actualDifference + 4; + }; + + const adjustNewDate = (newDate, days) => { + while (days > 0) { + newDate.setDate(newDate.getDate() + 1); + if (!excludeWeekends || (newDate.getDay() !== 0 && newDate.getDay() !== 6)) { + days--; + } + } + }; + + const findNextValidDateAdd = (newDate, finalDateStr, filter, isInFilter) => { + const isDateInFilter = (dateStr) => filter.includes(dateStr); + while (isInFilter ? isDateInFilter(finalDateStr) : !isDateInFilter(finalDateStr)) { + newDate.setDate(newDate.getDate() + 1); + finalDateStr = formatDate(newDate); + } + return finalDateStr; + }; + + setFilterAndInFilter(type); + + if (["lautakunta", "lautakunta_määräaika"].includes(type)) { + const workdays = allDisabledDates?.date_types?.työpäivät?.dates; + let tempDate = new Date(newDate); + + if (originalDate && initialDistance) { + let actualDifference = calculateActualDifference(originalDate, tempDate, workdays); + if (actualDifference > initialDistance) { + addDays = false; + } + } + } + + if (type === "lautakunta_määräaika") { + const resultPastDate = getPastDate(originalDate, initialDistance, isInFilter, filter); + finalDateStr = formatDate(resultPastDate); + } else { + if (addDays) { + adjustNewDate(newDate, days); + finalDateStr = formatDate(newDate); + finalDateStr = findNextValidDateAdd(newDate, finalDateStr, filter, isInFilter); + } else { + finalDateStr = date; + } + } + + return finalDateStr; + }; + + // Function to subtract days from a date and return in "YYYY-MM-DD" format + const subtractDays = (type, date, days, disabledDates, excludeWeekends, origDate, allDisabledDates, initialDistance) => { + let newDate = new Date(date); + let originalDate = origDate ? new Date(origDate) : false; + let filter = ""; + let subtractDays = true; + let finalDateStr; + let isInFilter = true; + + const setFilterAndInFilter = (type) => { + if (["työpäivät", "esilläolopäivät", "arkipäivät", "lautakunta", "lautakunta_määräaika"].includes(type)) { + filter = disabledDates; + isInFilter = false; + } + }; + + const calculateActualDifference = (originalDate, tempDate, workdays) => { + let actualDifference = 0; + let calculate = true + while (calculate) { + if (excludeWeekends && (tempDate.getDay() === 0 || tempDate.getDay() === 6)) { + tempDate.setDate(tempDate.getDate() - 1); + } else if (!checkArrayForValue(workdays, tempDate)) { + tempDate.setDate(tempDate.getDate() - 1); + } else { + tempDate.setDate(tempDate.getDate() - 1); + actualDifference++; + } + calculate = tempDate >= originalDate + } + return actualDifference - 4; + }; + + const adjustNewDate = (newDate, days) => { + while (days > 0) { + newDate.setDate(newDate.getDate() - 1); + if (!excludeWeekends || (newDate.getDay() !== 0 && newDate.getDay() !== 6)) { + days--; + } + } + }; + + const findNextValidDateSubstract = (newDate, finalDateStr, filter, isInFilter) => { + const isDateInFilter = (dateStr) => filter.includes(dateStr); + while (isInFilter ? isDateInFilter(finalDateStr) : !isDateInFilter(finalDateStr)) { + newDate.setDate(newDate.getDate() - 1); + finalDateStr = formatDate(newDate); + } + return finalDateStr; + }; + + setFilterAndInFilter(type); + + if (["lautakunta", "lautakunta_määräaika"].includes(type)) { + const workdays = allDisabledDates?.date_types?.työpäivät?.dates; + let tempDate = new Date(newDate); + + if (originalDate && initialDistance) { + let actualDifference = calculateActualDifference(originalDate, tempDate, workdays); + if (actualDifference < initialDistance) { + subtractDays = false; + } + } + } + + if (type === "lautakunta_määräaika") { + const resultPastDate = getPastDate(originalDate, initialDistance, isInFilter, filter); + finalDateStr = formatDate(resultPastDate); + } else { + if (subtractDays) { + adjustNewDate(newDate, days); + finalDateStr = formatDate(newDate); + finalDateStr = findNextValidDateSubstract(newDate, finalDateStr, filter, isInFilter); + } else { + finalDateStr = date; + } + } + return finalDateStr; + }; + + const checkArrayForValue = (arr,expectedDate) => { + let index = 0; + let isValid = false; // Flag to track the validity of the array + + // Use a while loop to iterate through the array + while (index < arr.length) { + // Check if the current array element matches the expected date + if (arr[index] === formatDate(expectedDate)) { + isValid = true; // Set flag to false + break; // Stop execution if a mismatch is found + } + + index++; + } + + return isValid; + } + + //calculate new date from original date when removing number of days + const subtractDaysFromDate = (dateString, days) => { + const date = new Date(dateString); + date.setDate(date.getDate() - days); + + // Format the date to 'YYYY-MM-DD' + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // getMonth() returns 0-based month + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +// Function to shift dates forward only if the new date is greater or equal to the current one +const moveItemForward = (movedItemId, newStartDate, items) => { + // Find the item that is being moved forward + let itemIndex = items.findIndex(item => item.id === movedItemId); + if (itemIndex === -1) return; + + // Calculate the difference in time between the old and new start date + let item = items[itemIndex]; + let oldStartDate = item.start; + let timeDifference = newStartDate - oldStartDate; // in milliseconds + + // Check if the new start date is after the old one + if (timeDifference <= 0) { + console.log("Cannot move item backward."); + return; + } + + // Update the moved item's start and end date + item.start = newStartDate; + item.end = new Date(item.end.getTime() + timeDifference); + + // Update all subsequent items if their current start date is before or equal to the new moved date + for (let i = itemIndex + 1; i < items.length; i++) { + if (items[i].start <= item.end) { + // Shift start and end date of subsequent item + items[i].start = new Date(items[i].start.getTime() + timeDifference); + items[i].end = new Date(items[i].end.getTime() + timeDifference); + } + } +} + +// Check if a string is in "YYYY-MM-DD" format +const isDate = (value) => { + // Regular expression for YYYY-MM-DD format + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + return datePattern.test(value) && !isNaN(Date.parse(value)); +} + +const sortObjectByDate = (obj) => { + const sortedArray = []; + + // Process and sort only the keys with date strings + Object.keys(obj) + .filter(key => isDate(obj[key])) // Filter only date keys + .map(key => ({ key, date: new Date(obj[key]), value: obj[key] })) // Map keys to real Date objects and values + .sort((a, b) => a.date - b.date) // Sort by Date objects + .forEach(item => { + sortedArray.push({ key: item.key, value: item.value }); // Push each sorted key-value pair into the array + }); + + return sortedArray; // Returning an array guarantees the order +} +//Finds next possible date from from array if the value does not exist in it +const findNextPossibleValue = (array, value) => { + if (!Array.isArray(array) || typeof value !== 'string') { + throw new Error('Invalid input. Provide an array of strings and a value as a string.'); + } + + // Directly find the given value or the next possible value + for (const date of array) { + if (date >= value) { + return date; + } + } + + // If no value is found, return null or a message + return null; +} + +const calculateDisabledDates = (nahtavillaolo,size,dateTypes,name,formValues,sectionAttributes,currentDeadline) => { + const matchingItem = objectUtil.findMatchingName(sectionAttributes, name, "name"); + const previousItem = objectUtil.findItem(sectionAttributes, name, "name", -1); + const nextItem = objectUtil.findItem(sectionAttributes, name, "name", 1); +/* console.log("--------------------") + console.log("Previous item name",previousItem?.name) + console.log("Previous item PREV dist",previousItem?.distance_from_previous) + console.log("Previous item NEXT dist",previousItem?.distance_to_next) + console.log("--------------------") + console.log("This item name",matchingItem?.name) + console.log("This item PREV dist",matchingItem?.distance_from_previous) + console.log("This item NEXT dist",matchingItem?.distance_to_next) + console.log("--------------------") + console.log("Next item name",nextItem?.name) + console.log("Next item PREV DIST",nextItem?.distance_from_previous) + console.log("Next item NEXT DIST",nextItem?.distance_to_next) + console.log("--------------------") + console.log("Attribute PREVIOUS",formValues[previousItem?.name]) + console.log("Attribute NEXT",formValues[nextItem?.name]) + console.log("--------------------") */ + + if(name.includes("projektin_kaynnistys_pvm") || name.includes("kaynnistys_paattyy_pvm")){ + const miniumDaysBetween = nextItem?.distance_from_previous + const dateToCompare = name.includes("kaynnistys_paattyy_pvm") ? formValues[previousItem?.name] : formValues[nextItem?.name] + let newDisabledDates = dateTypes?.arkipäivät?.dates + const lastPossibleDateToSelect = name.includes("kaynnistys_paattyy_pvm") ? addDays("arkipäivät",dateToCompare,miniumDaysBetween,dateTypes?.arkipäivät?.dates,true) : subtractDays("arkipäivät",dateToCompare,miniumDaysBetween,dateTypes?.arkipäivät?.dates,true) + newDisabledDates = name.includes("kaynnistys_paattyy_pvm") ? newDisabledDates.filter(date => date > lastPossibleDateToSelect) : newDisabledDates.filter(date => date < lastPossibleDateToSelect) + return newDisabledDates + } + else if(name === "hyvaksymispaatos_valitusaika_paattyy" || name === "valitusaika_paattyy_hallinto_oikeus"){ + return dateTypes?.arkipäivät?.dates + } + else if(currentDeadline?.deadline?.deadlinegroup?.includes('lautakunta')){ + //Lautakunnat + if(name.includes("_maaraaika")){ + //Määräaika kasvaa loputtomasta. Puskee lautakuntaa eteenpäin + //Määräaika pienenee aiemman esilläolon loppuu minimiin. + const miniumDaysPast = matchingItem?.distance_from_previous ? matchingItem?.distance_from_previous : 5 //bug somewhere in backend should be 5 but is null + const dateToComparePast = formValues[matchingItem?.previous_deadline] + //Finds next possible working date to compare + const filteredDateToCompare= findNextPossibleValue(dateTypes?.työpäivät?.dates,dateToComparePast) + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("työpäivät",filteredDateToCompare,miniumDaysPast,dateTypes?.työpäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date >= firstPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_lautakunnassa")){ + //Lautakunta siirtyy eteenpäin loputtomasti. Vetää mukana määräaikaa. + //Lautakunta siirtyy taaksepäin minimiin. Vetää mukana määräaikaa ja pysähtyy minimiin. + //Needs to take inconcideration that the gap is 22 between lautakunta and määräaika and gap to phase start date previousItem?.distance_from_previous + const miniumDaysPast = matchingItem?.initial_distance.distance + previousItem?.distance_from_previous + //Phase start date + const dateToComparePast = formValues[matchingItem?.previous_deadline] ? formValues[matchingItem?.previous_deadline] : formValues[matchingItem?.initial_distance?.base_deadline] + //Finds next possible working date to compare + const filteredDateToCompare= findNextPossibleValue(dateTypes?.työpäivät?.dates,dateToComparePast) + //Array of the dates that are shown in calendar + let newDisabledDates = dateTypes?.lautakunnan_kokouspäivät?.dates + const firstPossibleDateToSelect = addDays("lautakunta",filteredDateToCompare,miniumDaysPast,dateTypes?.lautakunnan_kokouspäivät?.dates,true) + //Array of the dates that are shown in calendar after filter + newDisabledDates = newDisabledDates.filter(date => date >= firstPossibleDateToSelect) + return newDisabledDates + } + } + else if(!nahtavillaolo && (size === 'L' || size === 'XL')){ + if(name.includes("_maaraaika")){ + //Määräaika kasvaa loputtomasta. + //Määräaika pienenee minimiin. Vetää mukana alkaa ja loppuu päivämäärät. + const miniumDaysBetween = matchingItem?.distance_from_previous + const dateToCompare = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("työpäivät",dateToCompare,miniumDaysBetween,dateTypes?.työpäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_alkaa")){ + //Alku kasvaa päättyy minimiin asti. Vetää mukana määräajan. + //Alku pienenee minimiin eli määräaika minimiin. Määräaika liikkuu mukana. + const miniumDaysPast = matchingItem?.distance_from_previous + const miniumDaysFuture = matchingItem?.distance_to_next + const dateToComparePast = formValues[matchingItem?.previous_deadline] + const dateToCompareFuture = formValues[matchingItem?.next_deadline] + let newDisabledDates = dateTypes?.esilläolopäivät?.dates + const firstPossibleDateToSelect = addDays("esilläolopäivät",dateToComparePast,miniumDaysPast,dateTypes?.esilläolopäivät?.dates,true) + const lastPossibleDateToSelect = subtractDays("esilläolopäivät",dateToCompareFuture,miniumDaysFuture,dateTypes?.esilläolopäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect && date < lastPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_paattyy")){ + //Loppu kasvaa loputtomasti. + //Loppu pienenee alku minimiin asti. + const miniumDaysPast = matchingItem?.distance_from_previous + const dateToComparePast = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.esilläolopäivät?.dates + const firstPossibleDateToSelect = addDays("esilläolopäivät",dateToComparePast,miniumDaysPast,dateTypes?.esilläolopäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + } + else if(!nahtavillaolo && (size === 'XS' || size === 'S' || size === 'M')){ + if(name.includes("_maaraaika")){ + //Määräaika kasvaa loputtomasta. + //Määräaika pienenee minimiin. Vetää mukana alkaa ja loppuu päivämäärät. + const miniumDaysBetween = matchingItem?.distance_from_previous + const dateToCompare = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("työpäivät",dateToCompare,miniumDaysBetween,dateTypes?.työpäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_alkaa")){ + //Alku kasvaa päättyy minimiin asti. Vetää mukana määräajan. + //Alku pienenee minimiin eli määräaika minimiin. Määräaika liikkuu mukana. + const miniumDaysPast = matchingItem?.distance_from_previous + const miniumDaysFuture = matchingItem?.distance_to_next + const dateToComparePast = formValues[matchingItem?.previous_deadline] + const dateToCompareFuture = formValues[matchingItem?.next_deadline] + let newDisabledDates = dateTypes?.esilläolopäivät?.dates + const firstPossibleDateToSelect = addDays("esilläolopäivät",dateToComparePast,miniumDaysPast,dateTypes?.esilläolopäivät?.dates,true) + const lastPossibleDateToSelect = subtractDays("esilläolopäivät",dateToCompareFuture,miniumDaysFuture,dateTypes?.esilläolopäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect && date < lastPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_paattyy")){ + //Loppu kasvaa loputtomasti. + //Loppu pienenee alku minimiin asti. + const miniumDaysPast = matchingItem?.distance_from_previous + const dateToComparePast = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.esilläolopäivät?.dates + const firstPossibleDateToSelect = addDays("esilläolopäivät",dateToComparePast,miniumDaysPast,dateTypes?.esilläolopäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + } + else if(nahtavillaolo && size === 'L' || size === 'XL'){ + if(name.includes("_alkaa")){ + //Alku kasvaa min. Päättyy ei muutu. + //Alku pienenee min. Päättyy ei muutu. + const miniumDaysPast = matchingItem?.distance_from_previous + const miniumDaysFuture = matchingItem?.distance_to_next + const dateToComparePast = formValues[matchingItem?.previous_deadline] + const dateToCompareFuture = formValues[matchingItem?.next_deadline] + let newDisabledDates = dateTypes?.arkipäivät?.dates + const firstPossibleDateToSelect = addDays("arkipäivät",dateToComparePast,miniumDaysPast,dateTypes?.arkipäivät?.dates,true) + const lastPossibleDateToSelect = subtractDays("arkipäivät",dateToCompareFuture,miniumDaysFuture,dateTypes?.arkipäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect && date < lastPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_paattyy")){ + //Loppu kasvaa loputtomasti. Alku ei muutu. + //Loppu pienenee alku minimiin asti. Alku ei muutu. + const miniumDaysPast = matchingItem?.distance_from_previous + const dateToComparePast = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.arkipäivät?.dates + const firstPossibleDateToSelect = addDays("arkipäivät",dateToComparePast,miniumDaysPast,dateTypes?.arkipäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + } + else if(nahtavillaolo && size === 'XS' || size === 'S' || size === 'M'){ + if(name.includes("_maaraaika")){ + //Määräaika kasvaa loputtomasta. + //Määräaika pienenee minimiin. Vetää mukana alkaa ja loppuu päivämäärät. + const miniumDaysBetween = matchingItem?.distance_from_previous + const dateToCompare = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("työpäivät",dateToCompare,miniumDaysBetween,dateTypes?.työpäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + if(name.includes("_alkaa")){ + //Alku kasvaa min. Päättyy ei muutu. + //Alku pienenee min. Päättyy ei muutu.) + const miniumDaysPast = matchingItem?.distance_from_previous + const miniumDaysFuture = matchingItem?.distance_to_next + const dateToComparePast = formValues[matchingItem?.previous_deadline] + const dateToCompareFuture = formValues[matchingItem?.next_deadline] + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("arkipäivät",dateToComparePast,miniumDaysPast,dateTypes?.arkipäivät?.dates,true) + const lastPossibleDateToSelect = subtractDays("arkipäivät",dateToCompareFuture,miniumDaysFuture,dateTypes?.arkipäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect && date < lastPossibleDateToSelect) + return newDisabledDates + } + else if(name.includes("_paattyy")){ + //Loppu kasvaa loputtomasti. Alku ei muutu. + //Loppu pienenee alku minimiin asti. Alku ei muutu. + const miniumDaysPast = matchingItem?.distance_from_previous + const dateToComparePast = formValues[matchingItem?.previous_deadline] + let newDisabledDates = dateTypes?.työpäivät?.dates + const firstPossibleDateToSelect = addDays("arkipäivät",dateToComparePast,miniumDaysPast,dateTypes?.arkipäivät?.dates,true) + newDisabledDates = newDisabledDates.filter(date => date > firstPossibleDateToSelect) + return newDisabledDates + } + } + //If not any of the above return arkipäivät + return dateTypes?.arkipäivät?.dates +} + +const compareAndUpdateDates = (data) => { + //Updates viimeistaan lausunnot values to paattyy if paattyy date is greater + const pairs = [ + ["viimeistaan_lausunnot_ehdotuksesta", "milloin_ehdotuksen_nahtavilla_paattyy"], + ["viimeistaan_lausunnot_ehdotuksesta_2", "milloin_ehdotuksen_nahtavilla_paattyy_2"], + ["viimeistaan_lausunnot_ehdotuksesta_3", "milloin_ehdotuksen_nahtavilla_paattyy_3"], + ["viimeistaan_lausunnot_ehdotuksesta_4", "milloin_ehdotuksen_nahtavilla_paattyy_4"] + ]; + + pairs.forEach(([key1, key2]) => { + if (data[key1] && data[key2]) { + const date1 = new Date(data[key1]).toISOString().slice(0, 10); + const date2 = new Date(data[key2]).toISOString().slice(0, 10); + if (date1 < date2) { + data[key1] = date2; + } + } + }); +}; + +export default { + isWeekend, + addDays, + subtractDays, + formatDate, + subtractDaysFromDate, + moveItemForward, + sortObjectByDate, + dateDifference, + adjustDates, + isDate, + calculateWeekdayDifference, + isHoliday, + calculateDisabledDates, + getHighestDate, + findAllowedDate, + findAllowedLautakuntaDate, + compareAndUpdateDates +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4811e174a..71a0bcf1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6475,6 +6475,16 @@ __metadata: languageName: node linkType: hard +"d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": + version: 1.0.2 + resolution: "d@npm:1.0.2" + dependencies: + es5-ext: ^0.10.64 + type: ^2.7.2 + checksum: 775db1e8ced6707cddf64a5840522fcf5475d38ef49a5d615be0ac47f86ef64d15f5a73de1522b09327cc466d4dc35ea83dbfeed456f7a0fdcab138deb800355 + languageName: node + linkType: hard + "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" @@ -7329,6 +7339,18 @@ __metadata: languageName: node linkType: hard +"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.62, es5-ext@npm:^0.10.64, es5-ext@npm:~0.10.14": + version: 0.10.64 + resolution: "es5-ext@npm:0.10.64" + dependencies: + es6-iterator: ^2.0.3 + es6-symbol: ^3.1.3 + esniff: ^2.0.1 + next-tick: ^1.1.0 + checksum: 01179fab0769fdbef213062222f99d0346724dbaccf04b87c0e6ee7f0c97edabf14be647ca1321f0497425ea7145de0fd278d1b3f3478864b8933e7136a5c645 + languageName: node + linkType: hard + "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -7336,6 +7358,27 @@ __metadata: languageName: node linkType: hard +"es6-iterator@npm:^2.0.3": + version: 2.0.3 + resolution: "es6-iterator@npm:2.0.3" + dependencies: + d: 1 + es5-ext: ^0.10.35 + es6-symbol: ^3.1.1 + checksum: 6e48b1c2d962c21dee604b3d9f0bc3889f11ed5a8b33689155a2065d20e3107e2a69cc63a71bd125aeee3a589182f8bbcb5c8a05b6a8f38fa4205671b6d09697 + languageName: node + linkType: hard + +"es6-symbol@npm:^3.1.0, es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": + version: 3.1.4 + resolution: "es6-symbol@npm:3.1.4" + dependencies: + d: ^1.0.2 + ext: ^1.7.0 + checksum: 52125ec4b5d1b6b93b8d3d42830bb19f8da21080ffcf45253b614bc6ff3e31349be202fb745d4d1af6778cdf5e38fea30e0c7e7dc37e2aecd44acc43502055f9 + languageName: node + linkType: hard + "escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1" @@ -7754,6 +7797,18 @@ __metadata: languageName: node linkType: hard +"esniff@npm:^2.0.1": + version: 2.0.1 + resolution: "esniff@npm:2.0.1" + dependencies: + d: ^1.0.1 + es5-ext: ^0.10.62 + event-emitter: ^0.3.5 + type: ^2.7.2 + checksum: d814c0e5c39bce9925b2e65b6d8767af72c9b54f35a65f9f3d6e8c606dce9aebe35a9599d30f15b0807743f88689f445163cfb577a425de4fb8c3c5bc16710cc + languageName: node + linkType: hard + "espree@npm:^7.3.0, espree@npm:^7.3.1": version: 7.3.1 resolution: "espree@npm:7.3.1" @@ -7839,6 +7894,16 @@ __metadata: languageName: node linkType: hard +"event-emitter@npm:^0.3.5": + version: 0.3.5 + resolution: "event-emitter@npm:0.3.5" + dependencies: + d: 1 + es5-ext: ~0.10.14 + checksum: 27c1399557d9cd7e0aa0b366c37c38a4c17293e3a10258e8b692a847dd5ba9fb90429c3a5a1eeff96f31f6fa03ccbd31d8ad15e00540b22b22f01557be706030 + languageName: node + linkType: hard + "eventemitter3@npm:^2.0.3": version: 2.0.3 resolution: "eventemitter3@npm:2.0.3" @@ -7969,6 +8034,15 @@ __metadata: languageName: node linkType: hard +"ext@npm:^1.7.0": + version: 1.7.0 + resolution: "ext@npm:1.7.0" + dependencies: + type: ^2.7.2 + checksum: ef481f9ef45434d8c867cfd09d0393b60945b7c8a1798bedc4514cb35aac342ccb8d8ecb66a513e6a2b4ec1e294a338e3124c49b29736f8e7c735721af352c31 + languageName: node + linkType: hard + "extend@npm:^3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -11186,6 +11260,7 @@ __metadata: jest-localstorage-mock: ^2.4.26 leaflet: ^1.7.1 moment: ^2.29.1 + moment-range: ^4.0.2 npm-run-all: ^4.1.5 oidc-client-ts: ^3.0.1 path-browserify: ^1.0.1 @@ -11223,7 +11298,10 @@ __metadata: reselect: ^4.1.7 sass: 1.69.4 semantic-ui-css: ^2.4.1 - semantic-ui-react: 2.1.4 + semantic-ui-react: 2.1.5 + vis-data: ^7.1.9 + vis-timeline: ^7.7.3 + vis-util: ^5.0.7 languageName: unknown linkType: soft @@ -11970,6 +12048,17 @@ __metadata: languageName: node linkType: hard +"moment-range@npm:^4.0.2": + version: 4.0.2 + resolution: "moment-range@npm:4.0.2" + dependencies: + es6-symbol: ^3.1.0 + peerDependencies: + moment: ">= 2" + checksum: ff7425eed2600b1450ae25a7d074fb1edbc7956199f9eca974d5b2bc7885912dd08205e55675eb1b8cbbdcb3647622748861822580bd7327e680ca2202f8ee31 + languageName: node + linkType: hard + "moment@npm:^2.29.1": version: 2.29.4 resolution: "moment@npm:2.29.4" @@ -12056,6 +12145,13 @@ __metadata: languageName: node linkType: hard +"next-tick@npm:^1.1.0": + version: 1.1.0 + resolution: "next-tick@npm:1.1.0" + checksum: 83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -15181,9 +15277,9 @@ __metadata: languageName: node linkType: hard -"semantic-ui-react@npm:2.1.4": - version: 2.1.4 - resolution: "semantic-ui-react@npm:2.1.4" +"semantic-ui-react@npm:2.1.5": + version: 2.1.5 + resolution: "semantic-ui-react@npm:2.1.5" dependencies: "@babel/runtime": ^7.10.5 "@fluentui/react-component-event-listener": ~0.63.0 @@ -15201,7 +15297,7 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 371de9540f11c4e78189de12a6cf81c8212ea698c93ad72904389462fb46c016207ce1781684a207c47a8ac790479e5d8810add957d9061fff929b546be14861 + checksum: 9279b986736f78f95d21836e8052d88b88a6cc60b8a6139cff557eae449ff0a7e71ca556bab69ad4c1625251fd0e1ff6fa1613c2c47274681b330a249733b89a languageName: node linkType: hard @@ -16484,6 +16580,13 @@ __metadata: languageName: node linkType: hard +"type@npm:^2.7.2": + version: 2.7.2 + resolution: "type@npm:2.7.2" + checksum: 0f42379a8adb67fe529add238a3e3d16699d95b42d01adfe7b9a7c5da297f5c1ba93de39265ba30ffeb37dfd0afb3fb66ae09f58d6515da442219c086219f6f4 + languageName: node + linkType: hard + "typed-array-length@npm:^1.0.4": version: 1.0.4 resolution: "typed-array-length@npm:1.0.4" @@ -16830,6 +16933,43 @@ __metadata: languageName: node linkType: hard +"vis-data@npm:^7.1.9": + version: 7.1.9 + resolution: "vis-data@npm:7.1.9" + peerDependencies: + uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + vis-util: ^5.0.1 + checksum: d299e5c9c301e9ecfaa3bd40eea0f89320ed2c8053e1105896d197561bc8927a08d715db33ba085efdedcaef08621b22fbdb32698fe611f94a2d9fac6e3f48b2 + languageName: node + linkType: hard + +"vis-timeline@npm:^7.7.3": + version: 7.7.3 + resolution: "vis-timeline@npm:7.7.3" + peerDependencies: + "@egjs/hammerjs": ^2.0.0 + component-emitter: ^1.3.0 + keycharm: ^0.2.0 || ^0.3.0 || ^0.4.0 + moment: ^2.24.0 + propagating-hammerjs: ^1.4.0 || ^2.0.0 + uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + vis-data: ^6.3.0 || ^7.0.0 + vis-util: ^5.0.1 + xss: ^1.0.0 + checksum: d11eeb5cdb64fc02c47d02a423c5a347996a265405f0c5b2157cc981e828dd951a78222d994e97795b82e98e48c1dcb1b1f23c615aa1df3e5b4c6091fc18de64 + languageName: node + linkType: hard + +"vis-util@npm:^5.0.7": + version: 5.0.7 + resolution: "vis-util@npm:5.0.7" + peerDependencies: + "@egjs/hammerjs": ^2.0.0 + component-emitter: ^1.3.0 || ^2.0.0 + checksum: d072b843de670741d4be0b95d7e16ad68ca996f537c97f4127d23c70bf795e9d8e619573a9658f311be9ec4ca8a192fb6e1035d6f3aff77049dd2e19c294afe9 + languageName: node + linkType: hard + "void-elements@npm:3.1.0": version: 3.1.0 resolution: "void-elements@npm:3.1.0"