diff --git a/src/app/App.tsx b/src/app/App.tsx index 9b471d3c..82e79b9e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -84,7 +84,9 @@ function App() { toast.success('Welcome back ' + res.username); }); api.interceptors.request.use(function (config) { - dispatch(showLoader(true)); + if (!config.url?.includes('task-details')) { + dispatch(showLoader(true)); + } // Inject Bearer token in every request config.headers = { Authorization: `Bearer ${keycloak.token}` @@ -101,7 +103,7 @@ function App() { }); } } else { - dispatch(showLoader(true)); + // dispatch(showLoader(true)); } }, [initialized, dispatch, keycloak]); diff --git a/src/assets/svgs/barChart.svg b/src/assets/svgs/barChart.svg new file mode 100644 index 00000000..018a87e4 --- /dev/null +++ b/src/assets/svgs/barChart.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/check-circle.svg b/src/assets/svgs/check-circle.svg new file mode 100644 index 00000000..1b078f50 --- /dev/null +++ b/src/assets/svgs/check-circle.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/eye-regular.svg b/src/assets/svgs/eye-regular.svg new file mode 100644 index 00000000..61090a6f --- /dev/null +++ b/src/assets/svgs/eye-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/eye-slash-regular.svg b/src/assets/svgs/eye-slash-regular.svg new file mode 100644 index 00000000..846d1150 --- /dev/null +++ b/src/assets/svgs/eye-slash-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/gaugeIcon.svg b/src/assets/svgs/gaugeIcon.svg new file mode 100644 index 00000000..83c45030 --- /dev/null +++ b/src/assets/svgs/gaugeIcon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/house.svg b/src/assets/svgs/house.svg new file mode 100644 index 00000000..98457e9f --- /dev/null +++ b/src/assets/svgs/house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/layers-icon.svg b/src/assets/svgs/layers-icon.svg new file mode 100644 index 00000000..ba5fd6da --- /dev/null +++ b/src/assets/svgs/layers-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/pen-solid.svg b/src/assets/svgs/pen-solid.svg new file mode 100644 index 00000000..fbf71b8d --- /dev/null +++ b/src/assets/svgs/pen-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/placeMarker.svg b/src/assets/svgs/placeMarker.svg new file mode 100644 index 00000000..84202b44 --- /dev/null +++ b/src/assets/svgs/placeMarker.svg @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/src/assets/svgs/remove-minus.svg b/src/assets/svgs/remove-minus.svg new file mode 100644 index 00000000..168d7748 --- /dev/null +++ b/src/assets/svgs/remove-minus.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/remove.svg b/src/assets/svgs/remove.svg new file mode 100644 index 00000000..15db817a --- /dev/null +++ b/src/assets/svgs/remove.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/assets/svgs/search.svg b/src/assets/svgs/search.svg new file mode 100644 index 00000000..9aab0703 --- /dev/null +++ b/src/assets/svgs/search.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svgs/sphere.svg b/src/assets/svgs/sphere.svg new file mode 100644 index 00000000..e0aeb19f --- /dev/null +++ b/src/assets/svgs/sphere.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svgs/tag-2.svg b/src/assets/svgs/tag-2.svg new file mode 100644 index 00000000..47c317aa --- /dev/null +++ b/src/assets/svgs/tag-2.svg @@ -0,0 +1,7 @@ + + + + + + tag-2 Created with Sketch Beta. + \ No newline at end of file diff --git a/src/assets/svgs/tag-icon.svg b/src/assets/svgs/tag-icon.svg new file mode 100644 index 00000000..87d6cca3 --- /dev/null +++ b/src/assets/svgs/tag-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/tagIcon.svg b/src/assets/svgs/tagIcon.svg new file mode 100644 index 00000000..dadff257 --- /dev/null +++ b/src/assets/svgs/tagIcon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/assets/svgs/task.svg b/src/assets/svgs/task.svg new file mode 100644 index 00000000..c2f5f622 --- /dev/null +++ b/src/assets/svgs/task.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/assets/svgs/times-svgrepo-com.svg b/src/assets/svgs/times-svgrepo-com.svg new file mode 100644 index 00000000..599faf30 --- /dev/null +++ b/src/assets/svgs/times-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/svgs/trash-bin.svg b/src/assets/svgs/trash-bin.svg new file mode 100644 index 00000000..8686bc51 --- /dev/null +++ b/src/assets/svgs/trash-bin.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/svgs/upload-svgrepo-com.svg b/src/assets/svgs/upload-svgrepo-com.svg new file mode 100644 index 00000000..e8a39074 --- /dev/null +++ b/src/assets/svgs/upload-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/svgs/users.svg b/src/assets/svgs/users.svg new file mode 100644 index 00000000..60ce25be --- /dev/null +++ b/src/assets/svgs/users.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/components/CustomModal/CustomModal.module.css b/src/components/CustomModal/CustomModal.module.css new file mode 100644 index 00000000..f98d1b1e --- /dev/null +++ b/src/components/CustomModal/CustomModal.module.css @@ -0,0 +1,42 @@ +.modal_backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 9999; /* Behind the modal */ +} + +.modal_content_backdrop { + position: fixed; /* Or fixed if outside parent constraints */ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + padding: 50px; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 10000; /* Above the backdrop */ +} + +.modal_content { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + /* top: 0; */ + /* right: -103.4%; */ + background: white; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1000; +} + +.modal_close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; +} diff --git a/src/components/CustomModal/CustomModal.tsx b/src/components/CustomModal/CustomModal.tsx new file mode 100644 index 00000000..23f9c84b --- /dev/null +++ b/src/components/CustomModal/CustomModal.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useRef } from 'react'; +import styles from './CustomModal.module.css'; + +interface CustomModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + sourceRef?: HTMLElement; + hasBackdrop?: boolean; +} + +function CustomModal({ isOpen, onClose, children, sourceRef, hasBackdrop = true }: CustomModalProps) { + const modalRef = useRef(null); + + const updateModalPosition = () => { + if (modalRef.current && sourceRef) { + const { top, right } = sourceRef.getBoundingClientRect(); + modalRef.current.style.top = `${top}px`; + modalRef.current.style.right = `${right}px`; + } + }; + + useEffect(() => { + if (isOpen) { + window.addEventListener('wheel', updateModalPosition, { passive: true }); + updateModalPosition(); + } + return () => { + window.removeEventListener('wheel', updateModalPosition); + }; + }, [isOpen, sourceRef]); + + if (!isOpen) return null; + + return ( +
{ + // Clean up the event listener + // (modalRef.current as any).removeEventListener('wheel', onWheelFeedback); + onClose(); + }} + > +
e.stopPropagation()} + > + + {children} +
+
+ ); +} + +export default CustomModal; diff --git a/src/components/CustomModal/useModal.tsx b/src/components/CustomModal/useModal.tsx new file mode 100644 index 00000000..151c37d3 --- /dev/null +++ b/src/components/CustomModal/useModal.tsx @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +export const useModal = () => { + const [isOpen, setIsOpen] = useState(false); + + const openModal = () => setIsOpen(true); + const closeModal = () => { + setIsOpen(false); + }; + + return { isOpen, openModal, closeModal }; +}; diff --git a/src/components/CustomPopup/CustomPop.module.css b/src/components/CustomPopup/CustomPop.module.css new file mode 100644 index 00000000..5c2e8375 --- /dev/null +++ b/src/components/CustomPopup/CustomPop.module.css @@ -0,0 +1,51 @@ +.popup { + z-index: 50; + min-width: 200px; +} + +.popupContent { + background: white; + border-radius: 0.5rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + border: 1px solid #e5e7eb; + /* padding: 0.25rem; */ +} + +.backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + animation: fadeIn 200ms ease-out; +} + +.centered { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: zoomIn 200ms ease-out; +} + +.contextual { + animation: fadeIn 200ms ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoomIn { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} diff --git a/src/components/CustomPopup/CustomPopup.tsx b/src/components/CustomPopup/CustomPopup.tsx new file mode 100644 index 00000000..c9788346 --- /dev/null +++ b/src/components/CustomPopup/CustomPopup.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './CustomPop.module.css'; + +interface PopupProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; + referenceElement?: HTMLElement | null; + hasBackdrop?: boolean; +} + +export function CustomPopup({ isOpen, onClose, children, referenceElement, hasBackdrop = false }: PopupProps) { + const popupRef = useRef(null); + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const mapContainer = document.getElementById('mapContainer'); + + const calculatePosition = useCallback(() => { + if (!referenceElement || !popupRef.current || hasBackdrop) return { top: 0, left: 0 }; + + const referenceRect = referenceElement.getBoundingClientRect(); + const popupRect = popupRef.current.getBoundingClientRect(); + const containerRect = mapContainer?.getBoundingClientRect() ?? { top: 0, bottom: Infinity }; + const scrollY = window.scrollY; + const scrollX = window.scrollX; + + let top = referenceRect.top + scrollY; + let left = referenceRect.left + 38; + + // Adjust position if popup goes beyond the container's bottom + if (top + popupRect.height > containerRect.bottom + scrollY - 60) { + top = containerRect.bottom - popupRect.height - 60; + } + + // Adjust position if popup goes beyond the viewport's right edge + if (left + popupRect.width > window.innerWidth + scrollX) { + left = referenceRect.right - popupRect.width + scrollX; + } + + return { top, left }; + }, [referenceElement, hasBackdrop, mapContainer]); + + const updatePosition = useCallback(() => { + const newPosition = calculatePosition(); + if (newPosition && (!position || newPosition.top !== position.top || newPosition.left !== position.left)) { + setPosition(newPosition); + } + }, [calculatePosition, position]); + + useEffect(() => { + if (!isOpen) return; + + updatePosition(); + const handleScrollResize = () => updatePosition(); + + window.addEventListener('scroll', handleScrollResize, true); + window.addEventListener('resize', handleScrollResize); + + return () => { + window.removeEventListener('scroll', handleScrollResize, true); + window.removeEventListener('resize', handleScrollResize); + }; + }, [isOpen, updatePosition]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + popupRef.current && + !popupRef.current.contains(event.target as Node) && + (!referenceElement || !referenceElement.contains(event.target as Node)) + ) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose, referenceElement]); + + useEffect(() => { + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscKey); + } + + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [isOpen, onClose]); + + if (!isOpen || !position) return null; + + return createPortal( + <> + {hasBackdrop &&
} +
e.stopPropagation()} + ref={popupRef} + style={ + hasBackdrop + ? undefined + : { + position: 'absolute', + top: `${position.top}px`, + left: `${position.left}px` + } + } + className={`${styles.popup} ${hasBackdrop ? styles.centered : styles.contextual}`} + > +
{children}
+
+ , + document.body + ); +} diff --git a/src/components/CustomStepper/CustomStepper.module.css b/src/components/CustomStepper/CustomStepper.module.css new file mode 100644 index 00000000..315c4885 --- /dev/null +++ b/src/components/CustomStepper/CustomStepper.module.css @@ -0,0 +1,131 @@ +.stepperWrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 23rem; + min-height: 18rem; + padding: 1rem; +} + +.stepperHeader { + font-size: 1.5rem; + margin-bottom: 1rem; +} + +.stepperContainer { + width: 100%; + max-width: 600px; +} + +.steps { + display: flex; + align-items: flex-start; + justify-content: space-around; + margin-bottom: 1rem; +} + +.stepWrapper { + display: flex; +} + +.stepNumberAndLabel { + display: flex; + flex-direction: column; + align-items: center; + width: 67px; + min-height: 63px; +} + +.step { + width: 3rem; + height: 3rem; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: #e0e0e0; + color: #000; + font-weight: bold; + transition: background-color 0.3s, color 0.3s; +} + +.activeStep { + background-color: #4e8ce9; + color: white; +} + +.completedStep { + background-color: #4e8ce9; + color: white; + cursor: pointer; +} + +.stepLabel { + margin-top: 0.5rem; + font-size: 0.9rem; + color: #666; + text-align: center; + word-wrap: break-word; + word-break: break-word; + white-space: normal; + line-height: 1.2; + display: block; +} + +.spacerLine { + height: 2px; + width: 90%; + background-color: #e0e0e0; + margin-top: 1.5rem; + transition: background-color 0.3s; +} + +.activeLine { + background-color: #4e8ce9; +} + +.completedLine { + background-color: #4e8ce9; +} + +.sectionContainer { + margin: 1rem 0; + min-height: 42.39px; +} + +.navigation { + display: flex; + justify-content: space-between; + width: 100%; + max-width: 400px; +} + +.controlButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; +} + +.nextButton { + background-color: #4e8ce9; + color: white; + transition: all 0.3s; +} + +.cancelButton { + background-color: #e0e0e0; + color: #000; +} + +.prevButton:disabled, +.nextButton:disabled { + background-color: #e0e0e0; + cursor: not-allowed; +} + +.nextButton:hover { + background-color: #3f7bd5; +} diff --git a/src/components/CustomStepper/CustomStepper.tsx b/src/components/CustomStepper/CustomStepper.tsx new file mode 100644 index 00000000..a36641af --- /dev/null +++ b/src/components/CustomStepper/CustomStepper.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import styles from './CustomStepper.module.css'; + +interface CustomStepperProps { + children: React.ReactNode; + stepLabels: string[]; + stepperHeader?: string; + onFinish?: { label: string; onClick: () => void }; + onClose?: () => void; + validation?: boolean; +} + +function CustomStepper({ + children, + stepLabels, + stepperHeader, + onClose, + onFinish, + validation = false +}: CustomStepperProps) { + const [activeStep, setActiveStep] = useState(0); + + const sections = React.Children.toArray(children).filter( + child => React.isValidElement(child) && child.type === 'section' + ); + const totalSteps = sections.length; + + // Navigate to next step + const goToNextStep = () => { + if (activeStep < totalSteps - 1) { + setActiveStep(prev => prev + 1); + } + }; + + // Navigate to previous step + const goToPreviousStep = () => { + if (activeStep > 0) { + setActiveStep(prev => prev - 1); + } + }; + + return ( +
+ {/* Stepper Header */} + {stepperHeader &&

{stepperHeader}

} +
+ {/* Step Indicators */} +
+ {sections.map((_, index) => ( + +
+
+ {/* Step Number or Check Icon */} +
(activeStep > index ? setActiveStep(index) : null)} + className={`${styles.step} ${ + activeStep === index ? styles.activeStep : index < activeStep ? styles.completedStep : '' + }`} + > + {index < activeStep ? '✓' : index + 1} +
+ {/* Step Label */} + +
+
+ {/* Spacer Line */} + {index < totalSteps - 1 && ( +
+ )} +
+ ))} +
+ + {/* Render Active Section */} +
+ {sections.map((section, index) => (activeStep === index ? section : null))} +
+ + {/* Navigation Controls */} +
+ + {activeStep === totalSteps - 1 && onFinish ? ( + + ) : ( + + )} +
+
+
+ ); +} + +export default CustomStepper; diff --git a/src/components/DrawerButton/DrawerButton.module.css b/src/components/DrawerButton/DrawerButton.module.css new file mode 100644 index 00000000..f5bea94d --- /dev/null +++ b/src/components/DrawerButton/DrawerButton.module.css @@ -0,0 +1,29 @@ +.button { + background-color: #ffffff; + border-radius: 0.375rem; + border: 1px solid #0000001a; + text-align: center; + text-decoration: none; + width: 100%; + color: black; + padding: 10px 20px; + font-weight: 500; + display: inline-block; + transition: transform 0.3s ease; + will-change: transform; + margin: 0; +} + +.button:hover { + transform: scale(1.02); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.button:focus-visible { + outline: none; +} + +.__disabled { + background-color: rgba(156, 154, 154, 0.1); + cursor: not-allowed; +} diff --git a/src/components/DrawerButton/DrawerButton.tsx b/src/components/DrawerButton/DrawerButton.tsx new file mode 100644 index 00000000..71478508 --- /dev/null +++ b/src/components/DrawerButton/DrawerButton.tsx @@ -0,0 +1,30 @@ +import { useRef } from 'react'; +import styles from './DrawerButton.module.css'; +import classNames from 'classnames'; + +interface DrawerButtonProps { + onClick: (ref: any) => void; + children: string; + disabled?: boolean; +} + +function DrawerButton({ onClick, children, disabled = false }: DrawerButtonProps) { + const buttonRef = useRef(null); + + return ( + + ); +} + +export default DrawerButton; diff --git a/src/components/DualRangeSlider/DualRangeSlider.module.css b/src/components/DualRangeSlider/DualRangeSlider.module.css new file mode 100644 index 00000000..584e59fb --- /dev/null +++ b/src/components/DualRangeSlider/DualRangeSlider.module.css @@ -0,0 +1,65 @@ +.container { + position: relative; + width: 100%; + height: 50px; +} + +.inactive { + pointer-events: none; +} + +.track { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 5px; + background-color: #e5e7eb; + border-radius: 9999px; +} + +.selectedRange { + position: absolute; + height: 100%; + border-radius: 9999px; +} + +.step { + position: absolute; + top: 56%; + width: 2px; + height: 5px; + margin-top: -3px; + background-color: #d1d5db; + pointer-events: none; +} + +.handle { + cursor: pointer; + position: absolute; + top: 62%; + width: 13px; + height: 13px; + margin-top: -10px; + background-color: white; + border-radius: 9999px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: box-shadow 0.2s; + /* border-style: solid; + border-width: 2px; */ +} + +.handle:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.values { + position: absolute; + bottom: -8px; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + font-size: 0.875rem; + color: #4b5563; +} diff --git a/src/components/DualRangeSlider/DualRangeSlider.tsx b/src/components/DualRangeSlider/DualRangeSlider.tsx new file mode 100644 index 00000000..41c50a1a --- /dev/null +++ b/src/components/DualRangeSlider/DualRangeSlider.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styles from './DualRangeSlider.module.css'; +import { Color } from 'react-color-palette'; +interface DualRangeSliderProps { + min: number; + max: number; + defaultMinValue?: number; + step?: number; + defaultMaxValue?: number; + onChange?: (minValue: number, maxValue: number) => void; + inactive?: boolean; + color?: Color; +} + +export const DualRangeSlider: React.FC = ({ + min, + max, + defaultMinValue = min, + defaultMaxValue = max, + onChange, + inactive = false, + color = { + hex: '#808080', + rgb: { r: 128, g: 128, b: 128 }, + hsl: { h: 0, s: 0, l: 0.5 } + } +}) => { + const [minValue, setMinValue] = useState(defaultMinValue); + const [maxValue, setMaxValue] = useState(defaultMaxValue); + const [isDragging, setIsDragging] = useState<'min' | 'max' | null>(null); + const sliderRef = useRef(null); + + useEffect(() => { + setMinValue(defaultMinValue); + setMaxValue(defaultMaxValue); + }, [defaultMinValue, defaultMaxValue]); + + const getPercentage = useCallback( + (value: number) => ((value - min) / (max - min)) * 100, + [min, max] + ); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + if (!isDragging || !sliderRef.current) return; + + const rect = sliderRef.current.getBoundingClientRect(); + const percentage = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100)); + const newValue = min + (percentage / 100) * (max - min); + + if (isDragging === 'min') { + if (newValue < maxValue) { + setMinValue(newValue); + onChange?.(newValue, maxValue); + } + } else if (isDragging === 'max') { + if (newValue > minValue) { + setMaxValue(newValue); + onChange?.(minValue, newValue); + } + } + }, + [isDragging, min, max, minValue, maxValue, onChange] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(null); + }, []); + + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+
+
+
+ + {/* Handles */} +
setIsDragging('min')} + /> +
setIsDragging('max')} + /> + + {/* Values */} +
+ {minValue.toFixed(2)} + {maxValue.toFixed(2)} +
+
+ ); +}; diff --git a/src/components/ExpandableMenu/ExpandableMenu.module.css b/src/components/ExpandableMenu/ExpandableMenu.module.css new file mode 100644 index 00000000..c8ce7022 --- /dev/null +++ b/src/components/ExpandableMenu/ExpandableMenu.module.css @@ -0,0 +1,91 @@ +.menuContainer { + position: absolute; + display: inline-flex; + align-items: center; + /* background-color: #ffffff; + border: 1px solid #e2e8f0; */ + border-radius: 7px; + height: 22px; + width: 22px; + padding: 4px; + right: 28px; + gap: 2px; + overflow: hidden; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.menuContainer.left { + flex-direction: row-reverse; + /* right: auto; */ + /* left: 28px; */ +} + +.menuContainer:hover { + width: 103px; + background-color: #ffffff; + /* border: 1px solid #e2e8f0; */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.kebabIcon { + min-width: 12px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + color: #64748b; +} + +.menuContainer:hover .kebabIcon { + transform: rotate(90deg); +} + +.menuItems { + display: flex; + gap: 8px; + align-items: center; + opacity: 0; + transform: translateX(-10px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.menuContainer:hover .menuItems { + opacity: 1; + transform: translateX(0); +} + +.menuContainer.left .menuItems { + transform: translateX(10px); +} + +.menuContainer.left:hover .menuItems { + transform: translateX(0); + padding-right: 3px; + padding-left: 3px; +} + +.menuItem { + min-width: 20px; + height: 20px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; + border: none; + background: transparent; + padding: 0; + opacity: 0.6; +} + +.menuItem:hover { + transition: all 0.3s; + opacity: 1; +} + +.menuItem.active { + color: #3b82f6; +} diff --git a/src/components/ExpandableMenu/ExpandableMenu.tsx b/src/components/ExpandableMenu/ExpandableMenu.tsx new file mode 100644 index 00000000..4206f908 --- /dev/null +++ b/src/components/ExpandableMenu/ExpandableMenu.tsx @@ -0,0 +1,30 @@ +import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ReactNode } from 'react'; +import styles from './ExpandableMenu.module.css'; + +interface ExpandableMenuProps { + direction?: 'left' | 'right'; + children?: ReactNode; + className?: string; +} + +export function ExpandableMenu({ direction = 'left', children, className }: ExpandableMenuProps) { + const containerClassName = [styles.menuContainer, styles[direction], className].filter(Boolean).join(' '); + + return ( +
{ + e.stopPropagation(); + }} + > +
+ +
+
{children}
+
+ ); +} + +export default ExpandableMenu; diff --git a/src/components/ExpandableMenu/MenuButton.tsx b/src/components/ExpandableMenu/MenuButton.tsx new file mode 100644 index 00000000..f25b4226 --- /dev/null +++ b/src/components/ExpandableMenu/MenuButton.tsx @@ -0,0 +1,17 @@ +import React, { ButtonHTMLAttributes } from 'react'; +import styles from './ExpandableMenu.module.css'; + +interface MenuButtonProps extends ButtonHTMLAttributes { + active?: boolean; +} + +function MenuButton({ children, className, active, ...props }: MenuButtonProps) { + const buttonClassName = [styles.menuItem, active && styles.active, className].filter(Boolean).join(' '); + return ( + + ); +} + +export default MenuButton; diff --git a/src/components/FileDrop/FileDrop.module.css b/src/components/FileDrop/FileDrop.module.css new file mode 100644 index 00000000..45977413 --- /dev/null +++ b/src/components/FileDrop/FileDrop.module.css @@ -0,0 +1,114 @@ +.fileDrop { + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + width: 100%; + + border: 2px dashed #cccccc; + border-radius: 10px; + padding: 2rem; + text-align: center; + transition: border-color 0.3s, background-color 0.3s; + + background-color: #f9f9f9; + display: flex; +} + +.fileDropImage { + width: 120px; + height: 120px; + margin: 0 auto; +} + +.fileNameWrapper { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 1px solid #cccccc; + border-radius: 7px; + padding: 0.5rem; +} + +.removeFileButton { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + border-radius: 4px; + + width: 15px; + height: 15px; + margin-left: 1rem; +} + +.removeFileIcon:hover { + background-color: #e8e8e8; +} + +.removeFileIcon { + border-radius: 4px; + width: 30px; + height: 30px; + transition: background-color 0.3s ease; +} + +.dragging { + border-color: #6ca6fd; + background-color: #e8f0ff; +} + +/* Spinner animation */ +.spinner { + border: 4px solid #ccc; + border-top: 4px solid #6ca6fd; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +.label { + font-weight: 500; + color: #333; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.fileListLabel { + text-align: left; +} + +.hiddenInput { + display: none; +} + +.chooseFileButton { + background-color: #6ca6fd; + color: white; + border: none; + padding: 10px 20px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + margin-top: 10px; + transition: background-color 0.3s ease; +} + +.chooseFileButton:hover { + background-color: #568ade; +} + +/* Keyframes for the spinner */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/components/FileDrop/FileDrop.tsx b/src/components/FileDrop/FileDrop.tsx new file mode 100644 index 00000000..7613bc7d --- /dev/null +++ b/src/components/FileDrop/FileDrop.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; +import styles from './FileDrop.module.css'; +import FileDropIcon from '../../assets/svgs/upload-svgrepo-com.svg'; +import RemoveIcon from '../../assets/svgs/times-svgrepo-com.svg'; + +interface FileDropProps { + multiFile?: boolean; +} + +function FileDrop({ multiFile = true }: FileDropProps) { + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + + const fileInputRef = React.useRef(null); + + useEffect(() => { + console.log(files); + }, [files]); + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDragging(false); + const droppedFiles = Array.from(event.dataTransfer.files); + if (!multiFile) { + setFiles(droppedFiles.slice(0, 1)); + } else { + const uniqueFiles = filterDuplicateFiles([...files, ...droppedFiles]); + setFiles(uniqueFiles); + } + }; + + const handleFileSelection = (event: React.ChangeEvent) => { + if (event.target.files) { + const selectedFiles = Array.from(event.target.files); + if (!multiFile) { + setFiles(selectedFiles.slice(0, 1)); + } else { + const uniqueFiles = filterDuplicateFiles([...files, ...selectedFiles]); + setFiles(uniqueFiles); + } + + event.target.value = ''; + } + }; + + const handleFileRemoval = (fileToRemove: File) => { + setFiles(prevFiles => prevFiles.filter(file => file !== fileToRemove)); + }; + + const filterDuplicateFiles = (fileArray: File[]) => { + const fileMap = new Map(); + fileArray.forEach(file => { + fileMap.set(file.name, file); + }); + return Array.from(fileMap.values()); + }; + + return ( +
+ file-upload +

Drag & Drop

+ + + {files.length > 0 && ( + <> + {files.map((file, index) => ( +
+

{file.name}

+
handleFileRemoval(file)} + aria-label={`Remove ${file.name}`} + > + remove file +
+
+ ))} + + )} +
+ ); +} + +export default FileDrop; diff --git a/src/components/Layout/Navbar/menuItems.ts b/src/components/Layout/Navbar/menuItems.ts index 90fdb363..f6cf4e77 100644 --- a/src/components/Layout/Navbar/menuItems.ts +++ b/src/components/Layout/Navbar/menuItems.ts @@ -15,7 +15,8 @@ import { REVEAL_MANAGE, RESOURCE_PLANNING_PAGE, DATA_PROCESSING_PROGRESS, - REVEAL_SIMULATION + REVEAL_SIMULATION, + CampaignManage } from '../../../constants'; export const MAIN_MENU = [ @@ -48,6 +49,11 @@ export const MAIN_MENU = [ pageTitle: 'Resource Planning', route: RESOURCE_PLANNING_PAGE, roles: [PLAN_VIEW] + }, + { + pageTitle: 'CampaignManage', + route: CampaignManage, + roles: [REVEAL_SIMULATION] } ] }, @@ -102,7 +108,7 @@ export const MAIN_MENU = [ pageTitle: 'dataProcessingProgress', route: DATA_PROCESSING_PROGRESS, roles: ['data_processing_progress'] - } + }, ] } ]; diff --git a/src/components/MapBox/index.css b/src/components/MapBox/index.css index 032a4c0b..3db72c40 100644 --- a/src/components/MapBox/index.css +++ b/src/components/MapBox/index.css @@ -8,16 +8,16 @@ .sidebar-adjust { z-index: 1; - margin: 1em; + /* margin: 1em; */ } .sidebar-adjust-list { z-index: 1; margin: 1em; - width:18% + width: 18%; } -.span-header{ +.span-header { font-size: medium; text-decoration-line: underline; font-weight: bold; @@ -75,11 +75,19 @@ ul { } .mapboxgl-popup-content { - font: 'BRFirma', 'Helvetica Neue', sans-serif; + /* font: 'BRFirma', 'Helvetica Neue', sans-serif; */ padding: 0 !important; + border-radius: 8px !important; width: 100%; - font-weight: 300; + background: rgba(255, 255, 255, 0.9) !important; + font-weight: 500; font-size: medium; + box-shadow: rgba(50, 50, 93, 0.25) 0px 13px 27px -5px, rgba(0, 0, 0, 0.3) 0px 8px 16px -8px !important; +} + +.mapboxgl-popup-tip { + border-top-color: rgba(255, 255, 255, 0.8) !important; + display: none !important; } .mapboxgl-popup-content h4 { @@ -92,20 +100,20 @@ ul { font-size: medium; } -.mapboxgl-popup-content p { +/* .mapboxgl-popup-content p { margin: 0; padding: 10px; -} +} */ -.mapboxgl-popup-content div { +/* .mapboxgl-popup-content div { padding: 10px; color: black !important; -} +} */ -.mapboxgl-popup-anchor-top>.mapboxgl-popup-content { +.mapboxgl-popup-anchor-top > .mapboxgl-popup-content { margin-top: 15px; } -.mapboxgl-popup-anchor-top>.mapboxgl-popup-tip { +.mapboxgl-popup-anchor-top > .mapboxgl-popup-tip { border-bottom-color: #91c949; -} \ No newline at end of file +} diff --git a/src/components/PageWrapper/PageWrapper.tsx b/src/components/PageWrapper/PageWrapper.tsx index 08d77708..22c9e86d 100644 --- a/src/components/PageWrapper/PageWrapper.tsx +++ b/src/components/PageWrapper/PageWrapper.tsx @@ -8,7 +8,7 @@ interface Props { const PageWrapper = ({ title, children }: Props) => { return ( - + {title ? ( <>

{title}

diff --git a/src/components/RangeInput/RangeInput.module.css b/src/components/RangeInput/RangeInput.module.css new file mode 100644 index 00000000..360e5c27 --- /dev/null +++ b/src/components/RangeInput/RangeInput.module.css @@ -0,0 +1,115 @@ +.rangeContainer { + width: 100%; + margin-bottom: 1rem; +} + +.rangeWrapper { + position: relative; + width: 100%; + height: 4px; + margin: 10px 0; +} + +.range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: transparent; + position: absolute; + top: 0; + left: 0; + margin: 0; + z-index: 2; + cursor: pointer; +} + +.range::-webkit-slider-runnable-track { + -webkit-appearance: none; + width: 100%; + height: 4px; + background: transparent; + border-radius: 2px; +} + +.range::-moz-range-track { + width: 100%; + height: 4px; + background: transparent; + border-radius: 2px; +} + +.range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background-color: var(--thumb-color, #3b82f6); + /* border: 2px solid #fff; */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 6px -1px rgba(0, 0, 0, 0.1); + cursor: pointer; + margin-top: -5px; + transition: transform 0.15s ease-in-out; +} + +.range::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--thumb-color, #3b82f6); + border: 2px solid #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 6px -1px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: transform 0.15s ease-in-out; +} + +.range::-webkit-slider-thumb:hover { + transform: scale(1.1); +} + +.range::-moz-range-thumb:hover { + transform: scale(1.1); +} + +.trackBackground { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #e2e8f0; + border-radius: 2px; + z-index: 1; +} + +.trackProgress { + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: 2px; + z-index: 1; + pointer-events: none; +} + +.valueContainer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.5rem; +} + +.value { + font-size: 0.875rem; + color: #64748b; + font-weight: 500; +} + +.label { + display: block; + font-size: 1rem; + font-weight: 500; + color: #1f2937; + margin-bottom: 0.5rem; +} diff --git a/src/components/RangeInput/RangeInput.tsx b/src/components/RangeInput/RangeInput.tsx new file mode 100644 index 00000000..538b8da0 --- /dev/null +++ b/src/components/RangeInput/RangeInput.tsx @@ -0,0 +1,67 @@ +import { ChangeEvent } from 'react'; +import styles from './RangeInput.module.css'; + +interface RangeInputProps { + min: number; + max: number; + step?: number; + value: number; + label?: string; + trackColor?: string; + thumbColor?: string; + onChange: (value: number) => void; +} + +function RangeInput({ + min, + max, + step = 1, + value, + label, + trackColor = '#3b82f6', + thumbColor = '#3b82f6', + onChange +}: RangeInputProps) { + const handleChange = (e: ChangeEvent) => { + onChange(Number(e.target.value)); + }; + + const percentage = ((value - min) / (max - min)) * 100; + + return ( +
+ {label && } +
+
+
+ +
+
+ {min} + {value} + {max} +
+
+ ); +} + +export default RangeInput; diff --git a/src/components/Router.tsx b/src/components/Router.tsx index 08165431..8289acc0 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -11,7 +11,8 @@ import { REPORTING_PAGE, RESOURCE_PLANNING_PAGE, SIMULATION_PAGE, - TAG_MANAGEMENT + TAG_MANAGEMENT, + CampaignManage } from '../constants/'; import Home from '../pages/HomePage'; import Plan from '../pages/Plan'; @@ -27,6 +28,7 @@ import MetaDataImport from '../pages/MetaDataImport'; import ResourcePlanning from '../pages/ResourcePlanning'; import DataProcessingProgress from '../features/technical/components/DataProcessingProgress'; import TagManagement2 from '../pages/TagManagement/TagManagement2'; +import Campaign from '../pages/Campaign'; const Router = () => { const { keycloak, initialized } = useKeycloak(); @@ -47,6 +49,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> }> diff --git a/src/components/Switch/Switch.module.css b/src/components/Switch/Switch.module.css new file mode 100644 index 00000000..9d53571d --- /dev/null +++ b/src/components/Switch/Switch.module.css @@ -0,0 +1,74 @@ +.container { + /* width: 100%; */ + max-width: 17rem; + margin: 0 auto; +} + +.switchContainer { + position: relative; + background-color: #f3f4f6; + padding: 4px; + border-radius: 0.5rem; + display: flex; + align-items: center; +} + +.switchBackground { + position: absolute; + height: calc(100% - 8px); + width: 48.5%; + background-color: white; + border-radius: 0.375rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + transition: transform 300ms ease-in-out; + top: 4px; + bottom: 4px; +} + +.switchBackground.right { + transform: translateX(100%); +} + +.option { + position: relative; + flex: 1; + width: 50%; + padding: 0.5rem 0; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.375rem; + border: none; + background: none; + cursor: pointer; + transition: color 300ms; +} + +.option.active { + color: #111827; +} + +.option.inactive { + color: #6b7280; +} + +.contentArea { + position: relative; + margin-top: 1rem; + overflow: hidden; + border-radius: 0.5rem; +} + +.contentWrapper { + display: flex; + width: 200%; + transition: transform 300ms ease-in-out; +} + +.contentWrapper.right { + transform: translateX(-50%); +} + +.content { + width: 50%; + flex-shrink: 0; +} diff --git a/src/components/Switch/Switch.tsx b/src/components/Switch/Switch.tsx new file mode 100644 index 00000000..0dbc6ef7 --- /dev/null +++ b/src/components/Switch/Switch.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import styles from './Switch.module.css'; + +interface SwitchProps { + leftOption: string; + rightOption: string; + leftContent: React.ReactNode; + rightContent: React.ReactNode; +} + +export function Switch({ leftOption, rightOption, leftContent, rightContent }: SwitchProps) { + const [isRight, setIsRight] = useState(false); + + return ( +
+
+
+ + + +
+ +
+
+
{leftContent}
+
{rightContent}
+
+
+
+ ); +} diff --git a/src/components/SwitchButton/SwitchButton.module.css b/src/components/SwitchButton/SwitchButton.module.css new file mode 100644 index 00000000..2a440283 --- /dev/null +++ b/src/components/SwitchButton/SwitchButton.module.css @@ -0,0 +1,50 @@ +.switchContainer { + display: flex; + align-items: center; +} + +.switchTitle { + margin-right: 1.5rem; +} + +.switchCheckbox { + height: 0; + width: 0; + visibility: hidden; +} + +.switchLabel { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + width: 35px; + height: 17px; + background: grey; + border-radius: 100px; + position: relative; + /* transition: background-color 0.1s; */ +} + +.switchLabel .switchButton { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 13px; + height: 13px; + border-radius: 45px; + transition: 0.2s; + background: #fff; + overflow: hidden; + box-shadow: 0 0 4px 0 rgba(10, 10, 10, 0.562); +} + +.switchCheckbox:checked + .switchLabel .switchButton { + left: calc(100% - 2px); + transform: translateX(-100%); +} + +.switchLabel:active .switchButton { + width: 13px; +} diff --git a/src/components/SwitchButton/SwitchButton.tsx b/src/components/SwitchButton/SwitchButton.tsx new file mode 100644 index 00000000..bf7a4ee8 --- /dev/null +++ b/src/components/SwitchButton/SwitchButton.tsx @@ -0,0 +1,24 @@ +import style from './SwitchButton.module.css'; + +interface SwitchButtonProps { + title: string; + isOn: boolean; + id: string; + handleToggle: (e: any) => void; + colorOne?: string; + colorTwo?: string; +} + +export default function SwitchButton({ title, id, isOn, handleToggle, colorOne, colorTwo }: SwitchButtonProps) { + return ( +
+ + + +
+ ); +} diff --git a/src/components/Table/SimulationResultExpandingTable.module.css b/src/components/Table/SimulationResultExpandingTable.module.css new file mode 100644 index 00000000..06e374eb --- /dev/null +++ b/src/components/Table/SimulationResultExpandingTable.module.css @@ -0,0 +1,39 @@ +.methodItemWrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + border-radius: 5px; + width: 100%; +} + +.methodItem { + width: 17px; + height: 17px; + border-radius: 5px; +} + +.name { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + padding: 0.5rem; +} + +.row { + display: flex; + align-items: center; + padding: 0.5rem 0; + cursor: pointer; + padding-right: 1rem; +} + +.expander { + cursor: pointer; + padding-right: 1rem; +} + +.details { + display: flex; + align-items: center; +} diff --git a/src/components/Table/SimulationResultExpandingTable.tsx b/src/components/Table/SimulationResultExpandingTable.tsx index e7f5f85c..f11186e5 100644 --- a/src/components/Table/SimulationResultExpandingTable.tsx +++ b/src/components/Table/SimulationResultExpandingTable.tsx @@ -1,18 +1,8 @@ -import React from 'react'; -import { Button, Table } from 'react-bootstrap'; -import { useExpanded, useTable } from 'react-table'; +import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - ROW_DEPTH_COLOR_1, - ROW_DEPTH_COLOR_2, - ROW_DEPTH_COLOR_3, - SIMULATION_LOCATION_TABLE_COLUMNS -} from '../../constants'; import { useAppSelector } from '../../store/hooks'; -import { useTranslation } from 'react-i18next'; import { MarkedLocation } from '../../features/planSimulation/components/Simulation'; -import { AnalysisLayer } from '../../features/planSimulation/components/Simulation'; -import { getBackgroundStyle } from '../../features/planSimulation/components/SimulationMapView/SimulationMapView'; +import styles from './SimulationResultExpandingTable.module.css'; interface Props { data: any; @@ -27,253 +17,68 @@ interface Props { const SimulationResultExpandingTable = ({ data, clickHandler, - detailsClickHandler, - summaryClickHandler, markedLocations, showOnlyMarkedLocations, markedParents }: Props) => { const isDarkMode = useAppSelector(state => state.darkMode.value); + const [expandedRows, setExpandedRows] = useState>(new Set()); - const getColorLevel = (depth: number) => { - if (depth === 0) { - return ''; - } else if (depth === 1) { - return ROW_DEPTH_COLOR_1; - } else if (depth === 2) { - return ROW_DEPTH_COLOR_2; - } else { - return ROW_DEPTH_COLOR_3; - } + const toggleRow = (identifier: string) => { + setExpandedRows(prev => { + const newExpandedRows = new Set(prev); + if (newExpandedRows.has(identifier)) { + newExpandedRows.delete(identifier); + } else { + newExpandedRows.add(identifier); + } + return newExpandedRows; + }); }; - const mapRows = (row: any): object[] => { - if (row.headOf !== undefined) { - return row.headOf.map((el: any) => { - return { - name: el.name, - identifier: el.identifier, - active: el.active.toString(), - headOf: el.headOf, - type: el.type.valueCodableConcept - }; - }); - } else if (row.children !== undefined && row.children.length > 0) { - return row.children.map((el: any) => { - return { - identifier: el.identifier, - children: el.children, - method: el.method, - properties: { - name: el.properties.name, - status: el.properties.status, - externalId: el.properties.externalId, - geographicLevel: el.properties.geographicLevel, - result: el.properties.result, - hasResultChild: el.properties.hasResultChild - }, - aggregates: el.aggregates - }; - }); - } else { - return []; - } + const renderRow = (row: any, depth: number) => { + const { identifier, properties } = row; + + const isMarked = markedLocations.some(location => location.identifier === identifier); + const isMarkedParent = markedParents.has(identifier); + const isExpanded = expandedRows.has(identifier); + + return ( +
+
{ + clickHandler(identifier); + toggleRow(identifier); + }} + > +
+ {/* {properties.hasResultChild && } */} + {row.children.length > 0 && } +
+
{properties.name}
+ {/*
+ +
*/} +
+ {isExpanded && row.children && row.children.map((childRow: any) => renderRow(childRow, depth + 1))} +
+ ); }; - const columns = React.useMemo( - () => [ - { - // Build our expander column - id: 'expander', // Make sure it has an ID - Cell: ({ row }: { row: any }) => - // Use the row.canExpand and row.getToggleRowExpandedProps prop getter - // to build the toggle for expanding a row - row.canExpand ? ( - - {row.isExpanded ? ( - - ) : ( - - )} - - ) : null - }, - ...SIMULATION_LOCATION_TABLE_COLUMNS - ], - [] - ); + // Filter data if showOnlyMarkedLocations is true + const filteredData = showOnlyMarkedLocations + ? data.filter((row: any) => markedLocations.some(location => location.identifier === row.identifier)) + : data; - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable( - { - columns, - data, - getSubRows: (row: any) => mapRows(row), - autoResetExpanded: false - }, - useExpanded // Use the useExpanded plugin hook - ); - const { t } = useTranslation(); return ( - - - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => { - return ( - - ); - })} - - ))} - - - {rows.map(row => { - prepareRow(row); - return ( - //row.depth is not existing in react table types for some reason, casting to any type solves the issue - - {row.cells - .filter(cell => { - const cellData = cell.row.original as any; - return ( - (showOnlyMarkedLocations && - (markedParents.has(cellData.identifier) || - markedLocations - .map(markedLocation => markedLocation.identifier) - .includes(cellData.identifier))) || - !showOnlyMarkedLocations - ); - }) - .map(cell => { - const cellData = cell.row.original as any; - if (cellData.properties?.hasResultChild || cellData.properties?.result) { - if (cell.column.id === 'resultName') { - return ( - - ); - } else if (cell.column.id === 'details') { - return ( - - ); - } else { - return ( - - ); - } - } - - return null; - })} - - ); - })} - -
- {column.Header !== undefined && column.Header !== null && column.id !== 'expander' - ? t('simulationPage.' + column.Header.toString()) - : ''} -
{ - if (cell.column.id !== 'expander') { - clickHandler(cellData.identifier); - } - }} - > - { - markedLocation.identifier) - .includes(cellData.identifier) - ? cellData.properties?.result - ? 'black' - : 'grey' - : 'red', - fontWeight: cellData.properties?.result ? 'bold' : 'normal' - }} - > - {cell.render('Cell')} - {cellData.method?.map((methodItem: AnalysisLayer) => ( -
- {' '} -
- ))} -
- }{' '} - {cellData.properties?.hasResultChild ? '*' : ''} - {markedParents.has(cellData.identifier) ? * : ''} -
{ - if (cell.column.id !== 'expander') { - clickHandler(cellData.identifier); - } - }} - > - {/*{*/} - {/* cellData.properties?.result?(*/} - - {!showOnlyMarkedLocations && ( - - )} - { - if (cell.column.id !== 'expander') { - let col = row.original as any; - clickHandler(col.identifier); - } - }} - > - {cell.render('Cell')} -
+
+ {filteredData.map((row: any) => renderRow(row, 0))} +
); }; diff --git a/src/constants/routes.tsx b/src/constants/routes.tsx index 521ed5a9..bb0e0f59 100644 --- a/src/constants/routes.tsx +++ b/src/constants/routes.tsx @@ -23,6 +23,8 @@ export const SIMULATION_PAGE = '/plans/simulation'; export const RESOURCE_PLANNING_PAGE = '/plans/resource-planning'; +export const CampaignManage = '/plans/campaign-management'; + //REPORTING PAGES export const REPORTING_PAGE = '/reports'; diff --git a/src/contexts/PolygonContext.tsx b/src/contexts/PolygonContext.tsx new file mode 100644 index 00000000..f68a192a --- /dev/null +++ b/src/contexts/PolygonContext.tsx @@ -0,0 +1,219 @@ +import { createContext, useContext, useReducer } from 'react'; + +type PolygonActions = + | { type: 'SET REPORT'; payload: any } + | { type: 'UPDATE_DATASET_OPACITY'; payload: { [id: string]: number } } + | { type: 'SET_ASSIGNED'; payload: { [identifier: string]: boolean } } + | { type: 'SET_PLANID'; payload: string } + | { type: 'SET_SIMULATION_ID'; payload: any } + | { type: 'SET_TARGET_AREAS'; payload: any[] } + | { type: 'SET_HIERARCHY'; payload: any[] } + | { type: 'SET_NEW_DATASETS'; payload: any[] } + | { type: 'SET_DATASET'; payload: any[] } + | { type: 'TOGGLE_DATASET_VISIBILITY'; payload: any } + | { type: 'ADD_DATASET'; payload: any } + | { type: 'UPDATE_DATASET'; payload: any } + | { type: 'DELETE_DATASET'; payload: any } + | { type: 'UPDATE_DATASET_FILTER'; payload: any } + | { type: 'SELECT_SINGLE'; payload: any } + | { type: 'TOGGLE_MULTISELECT'; payload: any } + | { type: 'SET_ADMIN0_LOCATION_ID'; payload: any } + | { type: 'SET_DEFAULT_HIERARCHY_DATA'; payload: any } + | { type: 'CLEAR_SELECTION' }; + +interface InitialStateInterface { + opacitySliderValue: { [id: string]: number }; + // using this as a map with assigned flags for all loaded children, + // as assigned flag changes and updated location data are not re-fetched from backend + locationReport: any; + assingedLocations: { [identifier: string]: boolean }; + simulationId: string; + planid: string; + polygons: any[]; + datasets: any[]; + selected: any | null; + multiselect: any[]; + admin0LocationId: string; + targetAreas: any[]; + defaultHierarchyData: any; +} + +const initialState: InitialStateInterface = { + locationReport: {}, + opacitySliderValue: {}, + assingedLocations: {}, + simulationId: '', + planid: '', + polygons: [], + datasets: [], + selected: null, + multiselect: [], + admin0LocationId: '', + targetAreas: [], + defaultHierarchyData: null +}; + +export interface SelectedPolygon { + assigned: boolean; + childrenNumber: number; + externalId: string; + id: string; + geographicLevel: string; + name: string; + parentIdentifier: string; + simulationSearchResult: boolean; + status: string; +} + +export interface PolygonContextInterface { + state: InitialStateInterface; + dispatch: React.Dispatch; +} + +// Reducer +function polygonReducer(state: InitialStateInterface, action: PolygonActions): InitialStateInterface { + switch (action.type) { + case 'SET REPORT': + return { ...state, locationReport: action.payload }; + case 'UPDATE_DATASET_OPACITY': + return { ...state, opacitySliderValue: { ...state?.opacitySliderValue, ...action.payload } }; + case 'SET_ASSIGNED': + return { ...state, assingedLocations: action.payload }; + case 'SET_PLANID': + return { ...state, planid: action.payload }; + case 'SET_SIMULATION_ID': + return { ...state, simulationId: action.payload }; + case 'SET_TARGET_AREAS': + return { ...state, targetAreas: action.payload }; + case 'SET_ADMIN0_LOCATION_ID': + return { ...state, admin0LocationId: action.payload }; + case 'SET_HIERARCHY': + return { ...state, polygons: action.payload }; + case 'SET_DEFAULT_HIERARCHY_DATA': + return { ...state, defaultHierarchyData: action.payload }; + case 'SET_NEW_DATASETS': + const datasets = + action.payload.length === 0 + ? [] + : action.payload.map((dataset: any) => ({ + ...dataset, + hidden: false, + selectedRange: { + minValue: dataset.selectedRange?.minValue || 0, + maxValue: dataset.selectedRange?.maxValue || 0 + }, + filter: { + minValue: dataset.filter?.minValue || 0, + maxValue: dataset.filter?.maxValue || 0 + } + })); + return { + ...state, + datasets + }; + case 'SET_DATASET': + return { + ...state, + datasets: state.datasets.map((d: any) => { + const corresponding = action.payload.find(ud => ud.identifier === d.identifier); + return { + ...corresponding, + filter: d.filter, + selectedRange: d.selectedRange, + hidden: d.hidden + }; + }) + }; + case 'DELETE_DATASET': + return { + ...state, + datasets: state.datasets.filter((dataset: any) => dataset.identifier !== action.payload) + }; + case 'UPDATE_DATASET': + return { + ...state, + datasets: state.datasets.map(dataset => + dataset.identifier === action.payload.datasetId + ? { ...dataset, filter: action.payload.filter, selectedRange: action.payload.filter } + : dataset + ) + }; + case 'UPDATE_DATASET_FILTER': + return { + ...state, + datasets: state.datasets.map(dataset => + dataset.identifier === action.payload.datasetId + ? { ...dataset, selectedRange: action.payload.filter || dataset.filter } + : dataset + ) + }; + case 'TOGGLE_DATASET_VISIBILITY': + return { + ...state, + datasets: state.datasets.map((dataset: any) => + dataset.identifier === action.payload.identifier ? action.payload : dataset + ) + }; + case 'ADD_DATASET': + return { + ...state, + datasets: [ + ...state.datasets, + { + ...action.payload, + hidden: false, + selectedRange: { + minValue: 0, + maxValue: 0 + }, + filter: { + minValue: 0, + maxValue: 0 + } + } + ] + }; + case 'SELECT_SINGLE': + if (JSON.stringify(state.selected) === JSON.stringify(action.payload?.properties)) { + return { ...state, selected: null }; + } else { + return { ...state, selected: action.payload?.properties, multiselect: [] }; + } + case 'TOGGLE_MULTISELECT': + const multiselect = state.multiselect.some((item: any) => item.properties.id === action.payload.properties.id) + ? state.multiselect.filter((item: any) => item.properties.id !== action.payload.properties.id) + : [...state.multiselect, action.payload]; + return { ...state, multiselect, selected: null }; + case 'CLEAR_SELECTION': + return { ...state, selected: null, multiselect: [] }; + default: + return state; + } +} + +// Context +const PolygonStateContext = createContext(initialState); +const PolygonDispatchContext = createContext(null); + +// Provider +export function PolygonProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(polygonReducer, initialState); + + return ( + + {children} + + ); +} + +// Custom Hook +export function usePolygonContext() { + const state = useContext(PolygonStateContext); + const dispatch = useContext(PolygonDispatchContext); + + if (state === undefined || dispatch === undefined) { + throw new Error('usePolygonContext must be used within a PolygonProvider'); + } + + return { state, dispatch }; +} diff --git a/src/features/location/components/DatasetsAccordion/DatasetsAccordion.module.css b/src/features/location/components/DatasetsAccordion/DatasetsAccordion.module.css new file mode 100644 index 00000000..6f6bf9f5 --- /dev/null +++ b/src/features/location/components/DatasetsAccordion/DatasetsAccordion.module.css @@ -0,0 +1,43 @@ +.popupWrapper { + padding-top: 1rem; + padding-right: 0.5rem; + padding-left: 0.5rem; + width: fit-content; +} + +.datasetInput { + width: 13.5rem; +} + +.datasetInput:focus, +input:focus { + outline: none; +} + +.switchPanel { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding-left: 1rem; + padding-right: 1rem; +} + +.rangeSwitchPicker { + margin-top: 1rem; + margin-left: 1rem; +} + +.icon { + width: 20px; + height: 20px; +} + +.iconPen { + width: 14px; + height: 14px; +} + +.datasetNameLabel { + margin-right: 1rem; +} diff --git a/src/features/location/components/DatasetsAccordion/DatasetsAccordion.tsx b/src/features/location/components/DatasetsAccordion/DatasetsAccordion.tsx new file mode 100644 index 00000000..61889fa2 --- /dev/null +++ b/src/features/location/components/DatasetsAccordion/DatasetsAccordion.tsx @@ -0,0 +1,286 @@ +import { useEffect, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Color, ColorPicker, useColor } from 'react-color-palette'; +import { CustomPopup } from '../../../../components/CustomPopup/CustomPopup'; +import { DualRangeSlider } from '../../../../components/DualRangeSlider/DualRangeSlider'; +import { Switch } from '../../../../components/Switch/Switch'; +import SwitchButton from '../../../../components/SwitchButton/SwitchButton'; +import RangeInput from '../../../../components/RangeInput/RangeInput'; +import ItemMenu from './ItemMenu'; + +import styles from '../accordion/Accordion.module.css'; +import DatasetStyles from './DatasetsAccordion.module.css'; +import { DataSetList, updateDataset } from '../../../planSimulation/components/SimulationMapView/api/datasetsAPI'; +import { usePolygonContext } from '../../../../contexts/PolygonContext'; +interface DatasetsAccordionProps { + open?: boolean; + dataset: DataSetList; + updateDatasetHandler: (datasetId: string) => void; + removeDatasetHandler: (datasetId: string) => void; +} + +function DatasetsAccordion({ + open = false, + dataset, + updateDatasetHandler, + removeDatasetHandler +}: DatasetsAccordionProps) { + const [range, setRange] = useState({ min: dataset.filter?.minValue, max: dataset.filter?.maxValue }); + const [currentRange, setCurrentRange] = useState({ + min: dataset.selectedRange?.minValue, + max: dataset.selectedRange?.minValue + }); + + const [isOpen, setOpen] = useState(open); + const [showModal, setShowModal] = useState(false); + + const [customColor, setCustomColor] = useColor('hex', dataset.hexColor); + // SLIDER + const [opacitySliderValue, setOpacitySliderValue] = useState(100); + + const [borderColor, setBorderColor] = useColor('hex', dataset?.borderColor); + const [borderValue, setBorderValue] = useState(dataset.lineWidth); + + const [checked, setChecked] = useState(false); + + const [isVisible, setIsVisible] = useState(false); + const [edit, setEdit] = useState(false); + + const [datasetName, setDatasetName] = useState(dataset.name); + const [tempName, setTempName] = useState(dataset.name); + + const colorPickerRef = useRef(null); + + const { dispatch } = usePolygonContext(); + const { state } = usePolygonContext(); + + const handleColorPopup = (event: any) => { + event.stopPropagation(); + setShowModal(!showModal); + }; + + const handleRangeChange = (minValue: number, maxValue: number) => { + setCurrentRange({ min: minValue, max: maxValue }); + dispatch({ + type: 'UPDATE_DATASET_FILTER', + payload: { + datasetId: dataset.identifier, + filter: { + minValue, + maxValue + } + } + }); + }; + + const handleDatasetUpdate = async () => { + try { + const UpdatedSimulation = await updateDataset({ + simulationId: state.simulationId, + datasetId: dataset.identifier, + name: tempName, + hexColor: customColor.hex, + lineWidth: borderValue, + borderColor: borderColor.hex + }); + updateDatasetHandler(UpdatedSimulation.datasets); + } catch (error) { + console.error('Failed to update dataset:', error); + } + }; + + const handleNameChange = (event: React.ChangeEvent) => { + setTempName(event.target.value); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setTempName(datasetName); // Revert to the committed value + setEdit(false); // Exit edit mode + } else if (event.key === 'Enter') { + setDatasetName(tempName); // Commit the new value + handleDatasetUpdate(); // Call the API to update the dataset + setEdit(false); // Exit edit mode + } + }; + + const removeDataset = async () => { + try { + removeDatasetHandler(dataset.identifier); + } catch (error) { + console.error('Failed to delete dataset:', error); + } + }; + + const handleChecked = () => { + setChecked(!checked); + setCurrentRange(range); + dispatch({ + type: 'UPDATE_DATASET_FILTER', + payload: { + datasetId: dataset.identifier, + filter: { + minValue: range.min, + maxValue: range.max + } + } + }); + }; + + const handleDatasetSliderValue = (newOpacity: number) => { + setOpacitySliderValue(newOpacity); + + dispatch({ + type: 'UPDATE_DATASET_OPACITY', + payload: { + [dataset.identifier]: newOpacity + } + }); + }; + + useEffect(() => { + setIsVisible(dataset.hidden); + setRange({ min: dataset.filter?.minValue, max: dataset.filter?.maxValue }); + setCurrentRange({ min: dataset.selectedRange?.minValue, max: dataset.selectedRange?.maxValue }); + }, [dataset]); + + return ( +
+
setOpen(!isOpen)} + style={{ position: 'relative' }} + > +
+ + dispatch({ + type: 'TOGGLE_DATASET_VISIBILITY', + payload: { + ...dataset, + hidden: !isVisible + } + }) + } + onEdit={() => { + setEdit(!edit); + }} + onDelete={() => { + removeDataset(); + }} + /> + { + setShowModal(false); + handleDatasetUpdate(); + }} + referenceElement={colorPickerRef.current} + hasBackdrop={false} + > +
+ + + +
+ } + rightContent={ +
+ + +
+ } + /> +
+ + + {edit ? ( + e.stopPropagation()} + /> + ) : ( + {datasetName} + )} + +
+
+
+
+ + +
+
+
+
+ ); +} + +export default DatasetsAccordion; diff --git a/src/features/location/components/DatasetsAccordion/ItemMenu.tsx b/src/features/location/components/DatasetsAccordion/ItemMenu.tsx new file mode 100644 index 00000000..f7dc192f --- /dev/null +++ b/src/features/location/components/DatasetsAccordion/ItemMenu.tsx @@ -0,0 +1,39 @@ +import ExpandableMenu from '../../../../components/ExpandableMenu/ExpandableMenu'; +import MenuButton from '../../../../components/ExpandableMenu/MenuButton'; +import Trash from '../../../../assets/svgs/trash-bin.svg'; +import Pen from '../../../../assets/svgs/pen-solid.svg'; +import Eye from '../../../../assets/svgs/eye-regular.svg'; +import EyeSlash from '../../../../assets/svgs/eye-slash-regular.svg'; +import styles from './DatasetsAccordion.module.css'; + +interface ItemMenuProps { + isVisible?: boolean; + direction?: 'left' | 'right'; + onToggleVisibility?: any; + onEdit?: () => void; + onDelete?: () => void; +} + +export function ItemMenu({ + isVisible = true, + direction = 'right', + onToggleVisibility, + onEdit, + onDelete +}: ItemMenuProps) { + return ( + + + Edit + + + show\hide + + + delete + + + ); +} + +export default ItemMenu; diff --git a/src/features/location/components/GaugeChart/GaugeChart.module.css b/src/features/location/components/GaugeChart/GaugeChart.module.css new file mode 100644 index 00000000..0be3d3cc --- /dev/null +++ b/src/features/location/components/GaugeChart/GaugeChart.module.css @@ -0,0 +1,49 @@ +.gaugeContainer { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 1rem; + border: 1px solid #0000001a; + border-radius: 5px; + background-color: white; +} + +.chartTitle { + font-size: 16px; + font-weight: bold; + margin-bottom: 8px; + display: flex; + align-items: center; +} + +.chartWrapper { + position: relative; + width: 180px; + height: 90px; +} + +.label { + position: absolute; + left: 50%; + transform: translate(-50%, -80%); + font-size: 1.2rem; + font-weight: bold; + cursor: pointer; +} + +.rangeLabels { + display: flex; + justify-content: space-between; + width: 100%; + max-width: 180px; + margin-top: 4px; + font-size: 14px; + color: #555; +} + +.gaugeIcon { + height: 20px; + width: 20px; + margin-right: 0.5rem; +} diff --git a/src/features/location/components/GaugeChart/GaugeChart.tsx b/src/features/location/components/GaugeChart/GaugeChart.tsx new file mode 100644 index 00000000..3a0e2cd9 --- /dev/null +++ b/src/features/location/components/GaugeChart/GaugeChart.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { Chart, ArcElement, Tooltip } from 'chart.js'; +import { Doughnut } from 'react-chartjs-2'; +import styles from './GaugeChart.module.css'; +import GaugeIcon from '../../../../assets/svgs/gaugeIcon.svg'; + +Chart.register(ArcElement, Tooltip); + +interface GaugeChartProps { + valueInPercentage: number; // Current value in percentage + absolutValue: number; // Current value + minValue: number; // Minimum value of the range + maxValue: number; // Maximum value of the range + label: string; // Chart label + color: string; // Gauge color +} + +export default function GaugeChart({ + absolutValue, + valueInPercentage, + minValue, + maxValue, + label, + color +}: GaugeChartProps) { + const [isHovered, setIsHovered] = useState(false); + const clampedValue = absolutValue; + const percentage = valueInPercentage; + + const formatNumber = (num: number): string => { + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)}m`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}k`; + return num.toString(); + }; + + const data = { + datasets: [ + { + data: [percentage, 100 - percentage], + backgroundColor: [color, '#E0E0E0'], + borderWidth: 0, + cutout: '60%', + circumference: 180, // Half-circle gauge + rotation: 270 + } + ] + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + tooltips: { enabled: false } + }; + + return ( +
+
+ Gauge Icon + {label} +
+
+ +
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + {isHovered ? formatNumber(clampedValue) : `${percentage.toFixed(0)}%`} +
+
+
+ {minValue} + {maxValue} +
+
+ ); +} diff --git a/src/features/location/components/StackedBarChart/StackedBarChart.module.css b/src/features/location/components/StackedBarChart/StackedBarChart.module.css new file mode 100644 index 00000000..1d274d29 --- /dev/null +++ b/src/features/location/components/StackedBarChart/StackedBarChart.module.css @@ -0,0 +1,26 @@ +.chartContainer { + padding: 1rem; + border: 1px solid #0000001a; + border-radius: 5px; + background-color: white; +} + +.title { + font-size: 1.2rem; + margin-bottom: 1rem; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} + +.chartWrapper { + height: 150px; + position: relative; +} + +.barChartIcon { + height: 20px; + width: 20px; + margin-right: 0.5rem; +} diff --git a/src/features/location/components/StackedBarChart/StackedBarChart.tsx b/src/features/location/components/StackedBarChart/StackedBarChart.tsx new file mode 100644 index 00000000..3deceaf1 --- /dev/null +++ b/src/features/location/components/StackedBarChart/StackedBarChart.tsx @@ -0,0 +1,75 @@ +import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import BarChartIcon from '../../../../assets/svgs/barChart.svg'; +import styles from './StackedBarChart.module.css'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); + +interface ChartData { + label: string; + data: number[]; + backgroundColor: string; +} + +interface StackedBarChartProps { + chartData?: ChartData[]; + title?: string; + max?: number; +} + +const StackedBarChart = ({ chartData, title = 'Structure Visit Status', max }: StackedBarChartProps) => { + const defaultData = { + labels: [''], + datasets: chartData || [ + { label: 'Complete', data: [0], backgroundColor: '#4CAF50' }, + { label: 'Incomplete', data: [0], backgroundColor: '#FFC107' }, + { label: 'Not Visited', data: [0], backgroundColor: '#F44336' } + ] + }; + + const options = { + indexAxis: 'y' as const, + responsive: true, + scales: { + x: { + stacked: true, + beginAtZero: true, + max: max || 100 + }, + y: { + stacked: true, + ticks: { display: false }, + grid: { display: false } + } + }, + plugins: { + tooltip: { + callbacks: { + afterBody: (context: any) => { + const value = context[0]?.raw || 0; + const total = 100; + return `${value} out of ${total} (${((value / total) * 100).toFixed(1)}%)`; + } + } + }, + legend: { + position: 'bottom' as const + } + }, + maintainAspectRatio: false + }; + + return ( +
+

+ BarChartIcon + {title} +

+
+ +
+
+ ); +}; + +export default StackedBarChart; diff --git a/src/features/location/components/accordion/Accordion.module.css b/src/features/location/components/accordion/Accordion.module.css new file mode 100644 index 00000000..9eab0d3e --- /dev/null +++ b/src/features/location/components/accordion/Accordion.module.css @@ -0,0 +1,65 @@ +.wrapper { + width: 600px; + margin: 0 auto; +} + +.accordion_Wrapper + * { + /* margin-top: 0.5em; */ +} + +.accordion_item { + overflow: hidden; + transition: max-height 0.3s cubic-bezier(1, 0, 1, 0); + height: auto; + max-height: 9999px; +} + +.parrentAccordion { + border-bottom: 1px solid #ddd; +} +.noBorder { + border-bottom: none !important; +} +.accordion_item.collapsed { + max-height: 0; + transition: max-height 0.35s cubic-bezier(0, 1, 0, 1); + /* border-bottom: 1px solid #ddd; */ +} +.accordion_title { + font-weight: 600; + cursor: pointer; + color: #666; + padding: 1rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.accordion_dataset { + font-weight: 600; + cursor: pointer; + color: #666; + padding: 0.7rem 0; + display: flex; + flex-direction: row-reverse; + align-items: center; + justify-content: flex-end; + gap: 1.5rem; +} + +.colorBox { + min-width: 20px; + min-height: 20px; + margin-left: auto; + border-radius: 5px; +} + +.accordion_title:hover, +.accordion_title.open { + color: black; +} + +.accordion_content { + padding: 0em 1.5em; + padding-bottom: 1rem; +} diff --git a/src/features/location/components/accordion/Accordion.tsx b/src/features/location/components/accordion/Accordion.tsx new file mode 100644 index 00000000..e226e6fe --- /dev/null +++ b/src/features/location/components/accordion/Accordion.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Accordion.module.css'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +interface AccordionProps { + title: string; + children: React.ReactNode; + open?: boolean; + parent?: boolean; + customTitle?: React.ReactNode; + removeBorderBottom?: boolean; + border?: boolean; +} + +function Accordion({ + title, + open = false, + children, + parent = true, + customTitle, + removeBorderBottom = false, + border = true +}: AccordionProps) { + const [isOpen, setOpen] = useState(open); + + useEffect(() => { + setOpen(open); + }, [open]); + + return ( +
+
{ + setOpen(!isOpen); + }} + style={{ position: 'relative' }} + > + {customTitle ? customTitle : {title}} + +
+
+
{children}
+
+
+ ); +} + +export default Accordion; diff --git a/src/features/location/components/doughnutChart/DoghnutChart.tsx b/src/features/location/components/doughnutChart/DoghnutChart.tsx new file mode 100644 index 00000000..adac232b --- /dev/null +++ b/src/features/location/components/doughnutChart/DoghnutChart.tsx @@ -0,0 +1,97 @@ +import { Chart as ChartJS, ArcElement, Tooltip, ChartData, ChartOptions } from 'chart.js'; +import { Doughnut } from 'react-chartjs-2'; +import { useRef, useEffect, useState } from 'react'; + +ChartJS.register(ArcElement, Tooltip); + +interface DoghnutChartProps { + data: ChartData<'doughnut'>; + cutoutPercentage?: number; + fontSize?: number; +} + +const createCustomLegendPlugin = (fontSize: number, threshold: number = 5) => ({ + id: 'customLegend', + afterDraw: (chart: any) => { + const { ctx, width, height } = chart; + const data = chart.data; + const centerX = width / 2; + const centerY = height / 2; + + // Calculate radius based on chart size + const chartArea = Math.min(width, height); + const radius = (chartArea * 0.85) / 2; // Outer radius of the doughnut + const labelDistance = radius + 10; // Labels positioned just outside the doughnut + + const total = data.datasets[0].data.reduce((sum: number, value: number) => sum + value, 0); + + let currentAngle = -0.5 * Math.PI; // Starting angle at top + + data.labels.forEach((label: string, index: number) => { + const value = data.datasets[0].data[index]; + const slicePercentage = (value / total) * 100; + + if (slicePercentage < threshold) { + currentAngle += (2 * Math.PI * value) / total; + return; + } + + const sliceAngle = (2 * Math.PI * value) / total; + const midAngle = currentAngle + sliceAngle / 2; + + const labelX = centerX + Math.cos(midAngle) * labelDistance; + const labelY = centerY + Math.sin(midAngle) * labelDistance; + + // Save the current context state + ctx.save(); + + // Set font for labels + ctx.font = `${fontSize}px Arial`; + ctx.fillStyle = '#333'; + + // Align text to the right or left based on angle + ctx.textAlign = midAngle < -Math.PI / 2 || midAngle > Math.PI / 2 ? 'right' : 'left'; + ctx.textBaseline = 'middle'; + + // Draw the label outside, centered on the slice + ctx.fillText(label, labelX, labelY); + + // Restore the context state + ctx.restore(); + + // Update the angle for the next slice + currentAngle += sliceAngle; + }); + }, +}); + +export function DoghnutChart({ data, cutoutPercentage = 40, fontSize = 14 }: DoghnutChartProps) { + const chartRef = useRef(null); + const [chartData, setChartData] = useState(data); + // Options for the Doughnut chart + const options: ChartOptions<'doughnut'> = { + cutout: `${cutoutPercentage}%`, + radius: '85%', // Full radius to make the chart bigger + responsive: true, // Ensures the chart resizes based on the container size + maintainAspectRatio: false, // Allows the chart to stretch based on its parent container + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: true + } + } + }; + + // Register the custom plugin for dynamic label positioning + const customLegendPlugin = createCustomLegendPlugin(fontSize); + ChartJS.register(customLegendPlugin); + + // Update chart data when props change + useEffect(() => { + setChartData(data); + }, [data]); + + return ; +} diff --git a/src/features/location/components/drawer/Drawer.tsx b/src/features/location/components/drawer/Drawer.tsx new file mode 100644 index 00000000..983b77a0 --- /dev/null +++ b/src/features/location/components/drawer/Drawer.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styleClasses from './drawer.module.css'; + +interface DrawerProps { + heading?: string; + anchor: string; + open: boolean; + children: React.ReactNode; +} + +export const Drawer = ({ children, open, anchor, heading }: DrawerProps) => { + const drawerRef = useRef(null); + const [isOverFlowing, setIsOverFlowing] = useState(false); + + useEffect(() => { + const checkOverflow = () => { + if (drawerRef.current) { + setIsOverFlowing(drawerRef.current.scrollHeight > drawerRef.current.clientHeight); + } + }; + + checkOverflow(); + + window.addEventListener('resize', checkOverflow); + return () => { + window.removeEventListener('resize', checkOverflow); + }; + }, [children]); + + return ( + <> +
+ {heading &&
{heading}
} +
{children}
+
+ + ); +}; diff --git a/src/features/location/components/drawer/drawer.module.css b/src/features/location/components/drawer/drawer.module.css new file mode 100644 index 00000000..66a89253 --- /dev/null +++ b/src/features/location/components/drawer/drawer.module.css @@ -0,0 +1,75 @@ +.drawer { + color: rgb(7, 7, 7); + background-color: #f9f9f9; + border: 1px solid #0000001a; + transition: width 0.3s ease; + position: relative; + display: flex; + overflow-y: scroll; + overflow-x: hidden; + flex-direction: column; + flex-shrink: 0; + height: 90vh; +} + +.scroll { + overflow-y: scroll; +} + +.left { + width: 0; +} + +.right { + width: 0; +} + +.open { + width: 309px; + height: 90vh; +} + +.closed { + width: 0; +} + +.heading { + flex-shrink: 0; + padding: 25px 20px; + font-size: 1.6rem; + white-space: nowrap; + overflow: hidden; + border-bottom: 1px solid #0000001a; +} + +.children { + flex-shrink: 0; + width: 309px; +} + +.customScroll::-webkit-scrollbar { + width: 5px; + display: none; +} + +.customScroll:hover::-webkit-scrollbar { + display: block; +} + +.customScroll::-webkit-scrollbar-track { + background: #0000001a; + margin: 4px; +} + +.customScroll::-webkit-scrollbar-thumb { + background-color: #949494; + border-radius: 10px; +} + +.customScroll::-webkit-scrollbar-button { + display: none !important; +} + +.customScroll::-webkit-scrollbar-thumb:hover { + background-color: #949494a1; +} diff --git a/src/features/location/components/locations/Locations.tsx b/src/features/location/components/locations/Locations.tsx index eda35167..0a421be8 100644 --- a/src/features/location/components/locations/Locations.tsx +++ b/src/features/location/components/locations/Locations.tsx @@ -152,9 +152,11 @@ const Locations = () => { const clearHandler = () => { setCurrentLocation(undefined); if (locationList && locationList.content.length) { - getLocationById(locationList.content[0].identifier).then(res => { - setCurrentLocation(res); - }).catch(err => toast.error(err)); + getLocationById(locationList.content[0].identifier) + .then(res => { + setCurrentLocation(res); + }) + .catch(err => toast.error(err)); } }; diff --git a/src/features/plan/api/index.ts b/src/features/plan/api/index.ts index ebd5bb50..caca44e4 100644 --- a/src/features/plan/api/index.ts +++ b/src/features/plan/api/index.ts @@ -55,7 +55,7 @@ export const updatePlanStatus = async (id: string): Promise => { export const updatePlanDetails = async (planDetails: PlanCreateModel, planId: string): Promise => { const data = await api.put(`${PLAN}/${planId}`, planDetails).then(response => response.data); return data; -} +}; export const createGoal = async (goal: Goal, planId: string): Promise => { const data = await api.post(`${PLAN}/${planId}/goal`, goal).then(response => response.data); @@ -85,9 +85,7 @@ export const updateAction = async (action: Action, planId: string, goalId: strin }; export const deleteAction = async (actionId: string, planId: string, goalId: string): Promise => { - const data = await api - .delete(`${PLAN}/${planId}/goal/${goalId}/action/${actionId}`) - .then(response => response.data); + const data = await api.delete(`${PLAN}/${planId}/goal/${goalId}/action/${actionId}`).then(response => response.data); return data; }; @@ -103,7 +101,12 @@ export const createCondition = async ( return data; }; -export const deleteCondition = async (condition: ConditionModel, planId: string, goalId: string, actionId: string): Promise => { +export const deleteCondition = async ( + condition: ConditionModel, + planId: string, + goalId: string, + actionId: string +): Promise => { const data = await api .delete(`${PLAN}/${planId}/goal/${goalId}/action/${actionId}/condition/${condition.identifier}`) .then(response => response.data); diff --git a/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.module.css b/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.module.css new file mode 100644 index 00000000..575d30d2 --- /dev/null +++ b/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.module.css @@ -0,0 +1,213 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 5000; +} + +.dialog { + background: white; + border-radius: 8px; + width: 100%; + max-width: 425px; + padding: 24px; + position: relative; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1); +} + +.header { + margin-bottom: 20px; +} + +.title { + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.searchContainer { + position: relative; + margin-bottom: 16px; +} + +.searchIcon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #6b7280; +} + +.searchInput { + width: 100%; + padding: 8px 12px 8px 36px; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 14px; +} + +.searchInput:focus { + outline: none; + border-color: #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.teamsList { + height: 300px; + overflow-y: auto; + margin: 16px 0; +} + +.teamButton { + width: 100%; + padding: 12px; + background: none; + border: 1px solid transparent; + border-radius: 8px; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + transition: all 0.2s; +} + +.teamButton:not(.inactive):hover { + background-color: #f3f4f6; +} + +.teamButton.selected { + background-color: #f3f4f6; + border-color: #10b981; +} + +.teamButton.inactive { + opacity: 0.6; + cursor: not-allowed; + background-color: #f3f4f6; +} + +.teamInfo { + display: flex; + flex-direction: column; + gap: 4px; +} + +.teamHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.teamName { + font-weight: 500; + color: #111827; +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-left: 4px; +} + +.statusDot.active { + background-color: #10b981; +} + +.statusDot.inactive { + background-color: #e5e7eb; +} + +.teamMembers { + font-size: 12px; + color: #6b7280; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; +} + +.cancelButton { + padding: 8px 16px; + border: 1px solid #e5e7eb; + background: white; + color: #374151; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.cancelButton:hover { + background-color: #f9fafb; +} + +.assignButton { + padding: 8px 16px; + border: none; + background-color: #059669; + color: white; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.unassignButton { + padding: 8px 16px; + border: none; + background-color: #f87171; + color: white; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.assignedTeam { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + border: 1px solid #e5e7eb; + background: white; + color: #374151; + font-size: 0.8rem; + border-radius: 6px; +} + +.unassignButton:hover { + background-color: #f87171; +} + +.assignButton:hover { + background-color: #047857; +} + +.assignButton:disabled { + background-color: #9ca3af; + cursor: not-allowed; +} + +.checkIcon { + width: 25px; +} + +.unnasignedSection { + padding: 8px 16px; + border: 1px solid #e5e7eb; + background: white; + color: #374151; + border-radius: 6px; +} diff --git a/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.tsx b/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.tsx new file mode 100644 index 00000000..cd4e207e --- /dev/null +++ b/src/features/planSimulation/components/AssignToTeamsDialog/AssignToTeamsDialog.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react'; +import styles from './AssignToTeamsDialog.module.css'; +import CheckIcon from '../../../../assets/svgs/check-circle.svg'; +import { usePolygonContext } from '../../../../contexts/PolygonContext'; +import { assignLocationToTeam, getLocationsAssignedToATeam } from './api/teamAssignmentAPI'; +import { getSimulationData } from '../SimulationMapView/api/datasetsAPI'; + +interface AssignToTeamsDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAssign: (teamId: number) => void; + teams: any[]; + selectedLocation: any; +} + +export function AssignToTeamsDialog({ + isOpen, + onOpenChange, + onAssign, + teams, + selectedLocation +}: AssignToTeamsDialogProps) { + const [selectedTeam, setSelectedTeam] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const { state, dispatch } = usePolygonContext(); + const planId = state.planid; + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!selectedLocation) return null; + + const assignedTeamsToLocation = JSON.parse(selectedLocation?.properties?.teamsAssigned || '[]'); + + // Extract identifiers from assigned teams + const assignedTeamIdentifiers = assignedTeamsToLocation.map((team: any) => team.identifier); + + const unassignedTeams = teams.filter(team => !assignedTeamIdentifiers.includes(team.identifier)); + const assignedTeams = teams.filter(team => assignedTeamIdentifiers.includes(team.identifier)); + // const selectedLocationsForTeam + + // console.log('Unassigned Teams', unassignedTeams); + // console.log('Assigned Teams', assignedTeamsToLocation); + + const handleAssign = async () => { + if (selectedTeam && selectedLocation) { + if (assignedTeams.some(team => team.identifier === selectedTeam)) { + assignLocationToTeam(selectedTeam, [''], planId); + console.log('Unassigning Location from Team'); + } else { + getLocationsAssignedToATeam(selectedTeam.toString(), planId).then(response => { + const selectedLocationsId = [...response, selectedLocation.properties.id]; + assignLocationToTeam(selectedTeam, selectedLocationsId, planId); + }); + console.log('Assigning Location to Team', selectedTeam); + } + const simulationData = await getSimulationData(state.planid); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + dispatch({ type: 'CLEAR_SELECTION' }); + + onAssign(selectedTeam); + setSelectedTeam(null); + setSearchQuery(''); + onOpenChange(false); + } + }; + + const handleClose = () => { + setSelectedTeam(null); + setSearchQuery(''); + onOpenChange(false); + }; + + // console.log('Selected Team', selectedTeam); + // console.log('AssignedTeams', assignedTeams); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

Select Team

+
+ +
+ {assignedTeams.length > 0 && ( + <> + {assignedTeams.map(team => ( + + ))} + + )} + {unassignedTeams.map(team => ( + + ))} +
+ +
+ + + {selectedTeam && assignedTeams.some(team => team.identifier === selectedTeam) ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/features/planSimulation/components/AssignToTeamsDialog/api/teamAssignmentAPI.ts b/src/features/planSimulation/components/AssignToTeamsDialog/api/teamAssignmentAPI.ts new file mode 100644 index 00000000..167e4942 --- /dev/null +++ b/src/features/planSimulation/components/AssignToTeamsDialog/api/teamAssignmentAPI.ts @@ -0,0 +1,16 @@ +import api from '../../../../../api/axios'; + +export const assignLocationToTeam = async (teamId: number, locationId: string[], planId: string) => { + const response = await api.post(`/plan/assignLocationsToTeam/${planId}`, { + organizationIdentifier: teamId, + locationIdentifiers: locationId + }); + + return response.data; +}; + +export const getLocationsAssignedToATeam = async (teamId: string, planId: string) => { + const response = await api.get(`/plan/${planId}/assigned-locations/${teamId}`); + + return response.data; +}; diff --git a/src/features/planSimulation/components/Campaign/Target.module.css b/src/features/planSimulation/components/Campaign/Target.module.css new file mode 100644 index 00000000..305fb6c8 --- /dev/null +++ b/src/features/planSimulation/components/Campaign/Target.module.css @@ -0,0 +1,65 @@ +.container { + display: flex; + flex-direction: column; +} + +.targetItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.3rem 0.8rem; + transition: background-color 0.3s; +} + +.targetItem:hover { + background-color: #f3f3f3; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #0ac600; + margin-right: 0; + margin-top: 10; +} + +.value { + font-size: 12px; + font-weight: bold; + color: #333; + display: flex; +} + +.text { + display: flex; + align-items: center; + gap: 10px; +} + +.hoverButton { + border: none; + cursor: pointer; + padding: 0; + background-color: transparent; + display: flex; + justify-content: center; + align-items: center; +} + +.hoverButton img { + transition: width 0.2s; + width: 0rem; + opacity: 0.7; + height: 17px; +} + +.targetItem:hover .hoverButton img { + visibility: visible; + width: 17px; + height: 17px; +} + +.targetItem:hover .hoverButton { + margin-left: 0.5rem; +} diff --git a/src/features/planSimulation/components/Campaign/Target.tsx b/src/features/planSimulation/components/Campaign/Target.tsx new file mode 100644 index 00000000..caa16d04 --- /dev/null +++ b/src/features/planSimulation/components/Campaign/Target.tsx @@ -0,0 +1,32 @@ +import style from './Target.module.css'; +import Delete from '../../../../assets/svgs/trash-bin.svg'; + +const Target = ({ targetAreas }: any) => { + console.log(); + + return ( + <> +
    + {targetAreas.targetAreasList.map((area: any, index: number) => ( +
  • +
    +
    +
    {area?.properties?.name || ''}
    +
    +
    + {/*
    {Math.round(area?.properties?.population?.sum) || ''}
    */} +
    {Math.round(area?.properties?.numberOfTeams)}
    + +
    +
  • + ))} +
+ + ); +}; + +export default Target; diff --git a/src/features/planSimulation/components/CampaignManagement.module.css b/src/features/planSimulation/components/CampaignManagement.module.css new file mode 100644 index 00000000..e5730b39 --- /dev/null +++ b/src/features/planSimulation/components/CampaignManagement.module.css @@ -0,0 +1,13 @@ +.modalButton{ + margin-top: 30px; + width: 200px; + color: white; + +} + +.modalContainer{ + margin-top: 30px; + width: 400px; + height: 100px; + +} \ No newline at end of file diff --git a/src/features/planSimulation/components/CampaignManagement.tsx b/src/features/planSimulation/components/CampaignManagement.tsx new file mode 100644 index 00000000..383a12a4 --- /dev/null +++ b/src/features/planSimulation/components/CampaignManagement.tsx @@ -0,0 +1,730 @@ +import { re } from 'mathjs'; +import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { Button, Col, Container, Form, Modal, Row } from 'react-bootstrap'; +import { useWindowResize } from '../../../hooks/useWindowResize'; +import { getGeneratedLocationHierarchyList, getLocationHierarchyList } from '../../location/api'; +import { LocationHierarchyModel } from '../../location/providers/types'; +import { Drawer } from '../../location/components/drawer/Drawer'; +import Accordion from '../../location/components/accordion/Accordion'; +import DrawerButton from '../../../components/DrawerButton/DrawerButton'; +import { CustomPopup } from '../../../components/CustomPopup/CustomPopup'; +import Target from './Campaign/Target'; +import Teams from './Teams/Teams'; +import UserModal from './UsersModal'; +import style from './CampaignManagement.module.css'; +import { bbox, Geometry, polygon } from '@turf/turf'; +import { LngLatBounds, Map as MapBoxMap } from 'mapbox-gl'; + +import { + getDefaultHierarchyData, + getHierarchy, + getHierarchyPolygon, + getPlanInfo +} from './SimulationMapView/api/hierarchyAPI'; +import Hierarchy from './Hierarchy/Hierarchy'; +import SimulationMapView from './SimulationMapView/SimulationMapView'; +import { Color } from 'react-color-palette'; +import { hex } from 'color-convert'; +import { usePolygonContext } from '../../../contexts/PolygonContext'; +import AddTargetAreaForm from './SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm'; +import AddDatasetForm from './SimulationMapView/components/AddDatasetForm/AddDatasetForm'; +import CampaignTotalsAccordion from './SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion'; +import DatasetsAccordion from '../../location/components/DatasetsAccordion/DatasetsAccordion'; +import Dashboard from './Dashboard/Dashboard'; + +import { + getDataAssociatedEntityTags, + getEntityList, + getEventBasedEntityTags, + getFullLocationsSSE, + getLocationsSSE, + submitSimulationRequest, + updateSimulationRequest +} from '../api'; +import { + ComplexTagResponse, + EntityTag, + HierarchyType, + LocationMetadataObj, + Metadata, + MetadataDefinition, + OperatorSignEnum, + PlanningLocationResponse, + PlanningLocationResponseTagged, + PlanningParentLocationResponse, + RevealFeature, + SearchLocationProperties +} from '../providers/types'; + +import { + AddDatasetResponse, + DataSetList, + deleteDataset, + getLocationPolygonsWithDatasets, + getSimulationData, + LocationData +} from './SimulationMapView/api/datasetsAPI'; +import { toast } from 'react-toastify'; +import { assignLocationsToPlan } from '../../assignment/api'; +import { getReportForLocation } from '../reportsAPI/reportsApi'; +import { getOrganizationListSummary, getOrganizatonsWithMembers } from './Teams/api/teamAPI'; + +export interface Stats { + [key: string]: Metadata; +} +export interface Children { + level: string; + childrenList: string[]; +} +export interface StatsLayer { + [layer: string]: Stats; +} +export interface MarkedLocation { + identifier: string; + ancestry: string[] | undefined; +} +export interface AnalysisLayer { + labelName: string; + color: Color; + colorHex: string; +} + +const CampaignManagement = () => { + const divRef = useRef(null); + const divHeight = useWindowResize(divRef.current); + const [mapFullScreen, setMapFullScreen] = useState(true); + const [showResult, setShowResult] = useState(false); + const [combinedHierarchyList, setCombinedHierarchyList] = useState(); + const [leftOpen, setLeftOpen] = useState(false); + const [rightOpen, setRightOpen] = useState(false); + const [showModal, setShowModal] = useState(false); + const [highestLocations, setHighestLocations] = useState(); + const [resultsLoadingState, setResultsLoadingState] = useState<'notstarted' | 'error' | 'started' | 'complete'>( + 'notstarted' + ); + const [parentsLoadingState, setParentsLoadingState] = useState<'notstarted' | 'error' | 'started' | 'complete'>( + 'notstarted' + ); + const [currentLocationId, setCurrentLocationId] = useState(); + const [polygonsWithData, setPolygonsWithData] = useState(); + const [includeGeometry, setIncludeGeometry] = useState(true); + const [selectedLocationChildren, setSelectedLocationChildren] = useState([]); + const [geometry, setGeometry] = useState(); + const [toLocation, setToLocation] = useState(); + const [resetMap, setResetMap] = useState(false); + const [mapData, setMapData] = useState(); + const [statsLayerMetadata, setStatsLayerMetadata] = useState({}); + const map = useRef(); + const [markedLocations, setMarkedLocations] = useState([]); + const [datasetList, setDatasetList] = useState([]); + const [openCustomModal, setOpenCustomModal] = useState(); + const [entityTags, setEntityTags] = useState([]); + const [entityTagsOriginal, setEntityTagsOriginal] = useState([]); + const [parentMapData, setParentMapData] = useState(); + const [parentChild, setParentChild] = useState<{ [parent: string]: Children }>({}); + const [analysisResultEntityTags, setAnalysisResultEntityTags] = useState(); + const [analysisLayerDetails, setAnalysisLayerDetails] = useState([]); + const [chartData, setChartData] = useState>({}); + const [totals, setTotals] = useState>({}); + const [selectedMapData, setSelectedMapData] = useState(); + const [summary, setSummary] = useState({}); + const levelsLoaded = useRef([]); + const [aggregationSummary, setAggregationSummary] = useState({}); + const [aggregationSummaryDefinition, setAggregationSummaryDefinition] = useState({}); + const [mapDataLoad, setMapDataLoad] = useState({ + features: [], + parents: [], + type: 'FeatureCollection', + identifier: undefined, + method: undefined, + source: undefined + }); + + const [locationReport, setLocationReport] = useState({}); + const [teamsList, setTeamsList] = useState([]); + + const { dispatch } = usePolygonContext(); + const { state } = usePolygonContext(); + const [labels, setLabels] = useState([]); + + const fetchSimulationAndData = async () => { + const simulationIdentifier = await fetchPlanInfo(); + + try { + const simulationData = await getSimulationData(simulationIdentifier); + dispatch({ type: 'SET_NEW_DATASETS', payload: simulationData.datasets }); + dispatch({ type: 'SET_SIMULATION_ID', payload: simulationData.identifier }); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + } catch (error) { + console.error('Failed to fetch simulation:', error); + } + }; + + const fetchDefaultHierarchyData = async () => { + const hierarchyData = await getDefaultHierarchyData(); + try { + dispatch({ type: 'SET_DEFAULT_HIERARCHY_DATA', payload: hierarchyData }); + } catch (error) { + console.error('Failed to fetch hierarchy data:', error); + } + }; + + useMemo(() => { + setDatasetList(state.datasets); + }, [state.datasets]); + + // we are updating selectedLocationChildren whenever an assignment happens, + // because assigned flag on these locations is not updated (it is still the one we got on location fetch) + useEffect(() => { + setSelectedLocationChildren(prev => + prev.map(obj => ({ + ...obj, + properties: { + ...obj.properties, + assigned: state.assingedLocations[obj.identifier] + } + })) + ); + }, [state.assingedLocations]); + + useEffect(() => { + if (currentLocationId && polygonsWithData && polygonsWithData[currentLocationId]) { + const children = Object.values(polygonsWithData) + .map((polygon: any) => polygon.polygonData) + .filter((polygon: any) => polygon.properties.parentIdentifier === currentLocationId); + + setSelectedLocationChildren(children); + + // when locations loaded, we are setting their assigned flag values as default values in assignment map + // this way, state.assignedLocations is our single source of truth + const assignedMap = children.reduce( + (map, obj) => { + return { + ...map, + [obj.identifier]: map[obj.identifier] ?? obj.properties.assigned + }; + }, + { ...state.assingedLocations } + ); + dispatch({ type: 'SET_ASSIGNED', payload: assignedMap }); + + const selectedLocation = polygonsWithData[currentLocationId].polygonData; + + if (selectedLocation) { + setGeometry(selectedLocation); + setToLocation(JSON.parse(JSON.stringify(bbox(selectedLocation.geometry)))); + + // report on location + getReportForLocation(state.planid, selectedLocation.identifier).then(report => { + setLocationReport(report); + }); + + const populationData = transformPopulationData(selectedLocation?.properties?.population); + if (populationData !== null) { + setChartData(populationData.chartData); + setLabels(populationData.labels); + setTotals(populationData.totals); + } + } + } + }, [currentLocationId, polygonsWithData]); + + useEffect(() => { + let populationData: any; + if (state.selected) { + populationData = transformPopulationData(JSON.parse(state.selected.population)); + + getReportForLocation(state.planid, state.selected.id).then(report => { + setLocationReport(report); + }); + if (populationData !== null) { + setChartData(populationData.chartData); + setLabels(populationData.labels); + setTotals(populationData.totals); + } + } else if (!state.selected && currentLocationId) { + const selectedLocation = polygonsWithData[currentLocationId].polygonData; + const populationData = transformPopulationData(selectedLocation?.properties?.population); + if (populationData !== null) { + setChartData(populationData.chartData); + setLabels(populationData.labels); + setTotals(populationData.totals); + } + } + }, [state.selected]); + + useEffect(() => { + fetchHierarchy(); + fetchSimulationAndData(); + fetchDefaultHierarchyData(); + }, []); + + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + + useEffect(() => { + Promise.all([ + getLocationHierarchyList(50, 0, true), + getEntityList(), + getGeneratedLocationHierarchyList() + // getComplexTagReponses() + ]) + .then(([locationHierarchyList, entityList, generatedHierarchyList]) => { + let generatedHierarchyItems = generatedHierarchyList?.map(generatedHierarchy => { + return { + identifier: generatedHierarchy.identifier, + name: generatedHierarchy.name, + nodeOrder: generatedHierarchy.nodeOrder, + type: HierarchyType.GENERATED + }; + }); + + let list = locationHierarchyList?.content.map(savedHierarchy => { + return { + identifier: savedHierarchy.identifier, + name: savedHierarchy.name, + nodeOrder: savedHierarchy.nodeOrder, + type: HierarchyType.SAVED + }; + }); + + let combinedList = list.concat(generatedHierarchyItems); + setCombinedHierarchyList(combinedList); + + // setComplexTags(complexTagResponses); + }) + .catch(err => toast.error(err)); + }, []); + + const updateMarkedLocations = (identifier: string, ancestry: string[] | undefined, marked: boolean) => { + setMarkedLocations(markedLocations => { + let newMarkedLocations: MarkedLocation[] = []; + + if (markedLocations) { + markedLocations.forEach(markedLocation => { + newMarkedLocations.push({ + identifier: markedLocation.identifier, + ancestry: markedLocation.ancestry + }); + }); + if (!marked) { + newMarkedLocations = newMarkedLocations?.filter(markedLocation => markedLocation.identifier !== identifier); + } else { + if (!newMarkedLocations.map(markedLocation => markedLocation.identifier).includes(identifier)) { + newMarkedLocations.push({ + identifier: identifier, + ancestry: ancestry + }); + } + } + } + return newMarkedLocations; + }); + }; + + useEffect(() => { + if (markedLocations.length > 0) { + let newMarkedParents = new Set(); + markedLocations.forEach(markedLocation => { + markedLocation.ancestry?.forEach(ancestor => { + if (ancestor !== markedLocation.identifier) { + newMarkedParents.add(ancestor); + } + }); + }); + } + }, [markedLocations]); + + const updateParentAsHasResultOrIsResult = ( + parent: RevealFeature, + lowestLocation: RevealFeature, + mapDataClone: PlanningLocationResponseTagged + ) => { + if (parent.children) { + if (!parent.children.map((locationChild: any) => locationChild.identifier).includes(lowestLocation.identifier)) { + parent.children.push(lowestLocation); + } + } else { + parent.children = []; + parent.children.push(lowestLocation); + if (parent.properties != null) { + if (parent.identifier) { + parent.properties.result = mapDataClone?.features[parent.identifier] != null; + } + } + } + if (parent.properties != null) { + if ( + !parent.properties.hasOwnProperty('hasResultChild') || + (parent.properties.hasOwnProperty('hasResultChild') && !parent.properties.hasResultChild) + ) { + if (lowestLocation.properties != null) { + parent.properties.hasResultChild = !!( + mapDataClone?.features[lowestLocation.properties.identifier] != null || + lowestLocation.properties?.hasResultChild || + lowestLocation.properties?.result + ); + } + } + + if ( + !parent.properties.hasOwnProperty('hasMarkedChild') || + (parent.properties.hasOwnProperty('hasMarkedChild') && !parent.properties.hasMarkedChild) + ) { + if (lowestLocation.properties != null) { + parent.properties.hasMarkedChild = !!( + lowestLocation.properties?.mark || lowestLocation.properties?.hasMarkedChild + ); + } + } + } + }; + + const getLocationHierarchyFromLowestLocation = useCallback( + (lowestLocation: RevealFeature, mapDataClone: PlanningLocationResponseTagged) => { + let parent: RevealFeature = mapDataClone?.parents[lowestLocation.properties?.parent]; + + if (parent) { + updateParentAsHasResultOrIsResult(parent, lowestLocation, mapDataClone); + setParentChild(newParentChild => { + if (lowestLocation?.identifier) { + if (parent.identifier) { + if (newParentChild[parent.identifier] && newParentChild[parent.identifier].childrenList.length > 0) { + if (!newParentChild[parent.identifier].childrenList.includes(lowestLocation?.identifier)) { + newParentChild[parent.identifier].childrenList.push(lowestLocation?.identifier); + } + } else { + newParentChild[parent.identifier] = { + level: lowestLocation.properties?.geographicLevel, + childrenList: [lowestLocation.identifier] + }; + } + newParentChild[parent.identifier].level = lowestLocation.properties?.geographicLevel; + } + } + return newParentChild; + }); + getLocationHierarchyFromLowestLocation(parent, mapDataClone); + } + }, + [] + ); + + useEffect(() => { + if (mapData && mapData?.features && Object.keys(mapData?.features).length > 0) { + let max = Number.MIN_VALUE; + + if (max != null) { + Object.keys(mapData?.features).forEach(key => { + if (mapData?.features[key]?.properties?.geographicLevelNodeNumber > max) { + max = mapData?.features[key]?.properties?.geographicLevelNodeNumber; + } + }); + + let lowestLocations: RevealFeature[] = Object.keys(mapData.features) + .filter(key => mapData.features[key].properties?.geographicLevelNodeNumber === max) + .map(key => { + return mapData.features[key]; + }) + .map((val: RevealFeature) => { + if (val.properties != null) { + val.properties.result = true; + } + return val; + }); + + if (!mapData.source || mapData.source !== 'uploadHandler') { + lowestLocations.forEach(lowestLocation => { + getLocationHierarchyFromLowestLocation(lowestLocation, mapData); + }); + } + } + + let min = Number.MAX_VALUE; + + if (min !== null) { + Object.keys(mapData?.parents).forEach(key => { + if (mapData?.parents[key]?.properties?.geographicLevelNodeNumber < min) { + min = mapData?.parents[key]?.properties?.geographicLevelNodeNumber; + } + }); + + // if (mapData.parents) { + // let highestLocations: any[] = Object.keys(mapData.parents) + // .filter( + // key => + // mapData.parents[key].properties !== null && + // mapData.parents[key].properties?.geographicLevelNodeNumber === min + // ) + // .map(key => mapData.parents[key]); + // // setHighestLocations(highestLocations); + // } + } + } + }, [mapData, getLocationHierarchyFromLowestLocation, markedLocations]); + + const fetchHierarchy = async () => { + const hierarchyData = await getHierarchy(); + try { + setHighestLocations(hierarchyData); + dispatch({ type: 'SET_HIERARCHY', payload: hierarchyData }); + } catch (error) { + console.error('Failed to fetch hierarchy:', error); + } + }; + const fetchPlanInfo = async () => { + try { + const planInfo = await getPlanInfo(); + dispatch({ type: 'SET_PLANID', payload: planInfo.identifier }); + return planInfo.identifier; + } catch (error) { + console.error('Failed to fetch plan info:', error); + } + }; + + const checkifChildrenLoaded = (polygonsWithData: any, selectedLocationId: any) => { + if (polygonsWithData?.[selectedLocationId]?.childrenLoaded === undefined) { + return true; + } else if (polygonsWithData?.[selectedLocationId]?.childrenLoaded === true) { + return false; + } else { + return true; + } + }; + + useEffect(() => { + fetchHierarchy(); + fetchSimulationAndData(); + }, []); + + const loadLocationHandler = async (locationId: string) => { + setCurrentLocationId(locationId); + if (state.datasets.length === 0 && polygonsWithData?.[locationId]?.childrenLoaded) { + setIncludeGeometry(false); + + const k = Object.values(polygonsWithData) + .map((polygon: any) => polygon.polygonData) + .filter(p => p.properties.parentIdentifier === locationId); + + setSelectedLocationChildren(k); + + const selectedLocation = polygonsWithData?.[locationId]?.polygonData; + if (selectedLocation) { + setGeometry(selectedLocation); + setToLocation(JSON.parse(JSON.stringify(bbox(selectedLocation.geometry)))); + } + } else { + const includeGeometry: boolean = checkifChildrenLoaded(polygonsWithData, locationId); + + let configObj: any = { + datasetsIds: [], + includeGeometry: includeGeometry, + parentLocationId: locationId, //current location identifier + simulationId: state.simulationId + }; + + const polygonsWithDatasets = await getLocationPolygonsWithDatasets(configObj); + if (!polygonsWithDatasets || polygonsWithDatasets.length === 0) { + toast.error('Cannot get results. Please try again.'); + return; + } + + if (includeGeometry) { + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + polygonsWithDatasets.forEach((location: any) => { + updatedPolygons[location.identifier] = { + polygonData: location, + childrenLoaded: location.identifier === locationId + }; + }); + + return updatedPolygons; + }); + } else { + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + // polygonsWithDatasets.forEach((location: any) => { + // updatedPolygons[location.identifier].polygonData.properties.metadata = location.properties.metadata; + // }); + + return updatedPolygons; + }); + } + } + }; + + const processChildren = useCallback( + (mapDataClone: any) => { + let geoLevel: string = mapDataClone.properties.geographicLevel; + + if (!summary[geoLevel]) { + summary[geoLevel] = {}; + } + summary[geoLevel][mapDataClone.identifier] = mapDataClone.properties; + summary[geoLevel][mapDataClone.identifier]['aggregates'] = mapDataClone.aggregates; + setSummary(summary); + + if (mapDataClone.children) { + mapDataClone.children.forEach((child: any) => processChildren(child)); + } + }, + [summary] + ); + + useEffect(() => { + if (Object.keys(summary).length === 0) { + if (selectedMapData) { + processChildren(selectedMapData); + } + } + }, [selectedMapData, summary, processChildren]); + + const handleRemoveTargetArea = (id: string) => { + const assignedAreas = state.targetAreas?.flatMap((ta: any) => [...ta.ancestry, ta.identifier]) || []; + const targetArea = state.targetAreas?.find(ta => ta.identifier === id); + const toExcludeSet = new Set([...targetArea.ancestry, id]); + const filtered = assignedAreas.filter(item => !toExcludeSet.has(item)); + assignLocationsToPlan(state.planid, filtered).then(async () => { + // update assignment map + dispatch({ type: 'SET_ASSIGNED', payload: { ...state.assingedLocations, [id]: false } }); + // refetch target areas, so the map updates + const simulationData = await getSimulationData(state.planid); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + dispatch({ type: 'CLEAR_SELECTION' }); + }); + }; + + const fetchTeamsData = async () => { + getOrganizatonsWithMembers().then((data: any) => { + setTeamsList(data); + }); + }; + + const campaignTotals = { + label: 'Target Areas', + total: state.targetAreas.length, + targetAreasList: state.targetAreas, + remove: handleRemoveTargetArea + }; + + return ( + <> + +
+ + {highestLocations && ( + + + + )} + + + setOpenCustomModal(1)}>Manage Teams + setOpenCustomModal(undefined)} hasBackdrop> + + + + + setLeftOpen(!leftOpen)} + leftOpenState={leftOpen} + rightOpenState={rightOpen} + rightOpenHandler={() => setRightOpen(!rightOpen)} + fullScreenHandler={() => { + setMapFullScreen(!mapFullScreen); + }} + fullScreen={mapFullScreen} + toLocation={toLocation} // bbox + entityTags={entityTags} + parentMapData={parentMapData} + setMapDataLoad={setMapDataLoad} + chunkedData={mapDataLoad} + resetMap={resetMap} + setResetMap={setResetMap} + stats={statsLayerMetadata} + resultsLoadingState={resultsLoadingState} + parentsLoadingState={parentsLoadingState} + map={map} + updateMarkedLocations={updateMarkedLocations} + parentChild={parentChild} + analysisLayerDetails={analysisLayerDetails} + /> + + {Object.keys(locationReport).length > 0 && ( + + + + )} + {campaignTotals.targetAreasList.length !== 0 && ( + + + + )} + +
+
+ + ); +}; + +export default CampaignManagement; + +const transformPopulationData = (population: any) => { + const mergedPyramids = mergeAgeGroups(population?.Pyramids) || []; + if (!mergedPyramids || mergedPyramids.length === 0) { + return null; + } + const ageGroups = mergedPyramids.map((group: any) => group.AgeGroup.replace('_', '-')); + const summaryData = mergedPyramids.map((group: any) => Math.round(group.TotalPop)); + const maleData = mergedPyramids.map((group: any) => Math.round(group.MalePop)); + const femaleData = mergedPyramids.map((group: any) => Math.round(group.FemalePop)); + + return { + labels: ageGroups, + chartData: { + summary: summaryData, + male: maleData, + female: femaleData + }, + totals: { + summary: Math.round(population.sum), + male: Math.round(population.male), + female: Math.round(population.female) + } + }; +}; + +const mergeAgeGroups = (pyramids: any[]) => { + if (!pyramids || pyramids.length === 0) return []; + const mergedGroups: any[] = []; + + for (let i = 0; i < pyramids.length; i += 2) { + const first = pyramids[i]; + const second = pyramids[i + 1] || null; + + const mergedGroup = { + AgeGroup: second + ? `${first.AgeGroup.split('_')[0]}-${second.AgeGroup.split('_')[1]}` + : first.AgeGroup.replace('_', '-'), + MalePop: Math.round(first.MalePop + (second?.MalePop || 0)), + FemalePop: Math.round(first.FemalePop + (second?.FemalePop || 0)), + TotalPop: Math.round(first.TotalPop + (second?.TotalPop || 0)) + }; + + mergedGroups.push(mergedGroup); + } + + return mergedGroups; +}; diff --git a/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.module.css b/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.module.css new file mode 100644 index 00000000..a0ccf186 --- /dev/null +++ b/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.module.css @@ -0,0 +1,55 @@ +.container { + width: 100%; + max-width: 28rem; + margin: 0 auto; +} + +.switchContainer { + padding: 4px; + position: relative; + background-color: #f3f4f6; + border-radius: 0.5rem; + display: flex; + align-items: center; +} + +.switchBackground { + position: absolute; + height: calc(100% - 8px); + width: 32.2%; /* Adjust width for each button */ + background-color: white; + border-radius: 0.375rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + transition: transform 300ms ease-in-out; +} + +/* Adjust position for each selected option */ +.switchBackground.middle { + transform: translateX(100%); /* Center on the middle button */ +} + +.switchBackground.right { + transform: translateX(200%); /* Move to the right button */ +} + +.option { + position: relative; + flex: 1; + width: 33.33%; + padding: 0.5rem 0; + font-size: 0.875rem; + font-weight: 500; + border: none; + background: none; + cursor: pointer; + transition: color 300ms; + text-align: center; /* Center text within each button */ +} + +.option.active { + color: #111827; /* Active text color */ +} + +.option.inactive { + color: #6b7280; /* Inactive text color */ +} diff --git a/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.tsx b/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.tsx new file mode 100644 index 00000000..d8acefc3 --- /dev/null +++ b/src/features/planSimulation/components/Dashboard/ChartSwitch/ChartSwitch.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import styles from './ChartSwitch.module.css'; + +interface SwitchProps { + leftOption: string; + middleOption: string; + rightOption: string; + onOptionChange: (option: string) => void; +} + +export function ChartSwitch({ leftOption, middleOption, rightOption, onOptionChange }: SwitchProps) { + const [selectedOption, setSelectedOption] = useState(leftOption); + + const handleOptionClick = (option: string) => { + setSelectedOption(option); + onOptionChange(option); // Notify the parent component + }; + + return ( +
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/features/planSimulation/components/Dashboard/Dashboard.module.css b/src/features/planSimulation/components/Dashboard/Dashboard.module.css new file mode 100644 index 00000000..9e60ac33 --- /dev/null +++ b/src/features/planSimulation/components/Dashboard/Dashboard.module.css @@ -0,0 +1,127 @@ +.statisticsWrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; +} + +.dashBoardSectionWrapper { + padding: 1rem; + border: 1px solid #0000001a; + border-radius: 5px; + background-color: white; +} + +.populationHeading { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.5rem; +} + +.populationHeadingIcon { + margin-right: 0.5rem; + font-size: 1.5rem; + color: #000000c4; +} + +.populationHeadingH3 { + margin: 0; + font-size: 1.2rem; +} + +.populationChartWrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +.populationChartSum { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding-top: 15px; +} + +.populationChartSumH3 { + font-size: 1rem; + margin: 0; +} + +.populationChartSumP { + font-size: 2rem; + font-weight: bold; + margin: 0; +} + +.doughnut { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.structureWrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.structureText { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.facilitiesStatisticWrapper { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +.facilitiesWrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.facilitiesText { + display: flex; + justify-content: space-between; + align-items: center; +} + +.houseIcon { + height: 17px; + width: 17px; + margin-right: 0.5rem; +} + +.chartControlsWrapper { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f3f4f6; + padding: 0.5rem; + border-radius: 5px; +} + +.chartControlsButton { + padding: 0.2rem 1.2rem; + border: none; + border-radius: 5px; +} + +.active { + color: #111827; + background-color: white; +} + +.inactive { + color: #6b7280; +} diff --git a/src/features/planSimulation/components/Dashboard/Dashboard.tsx b/src/features/planSimulation/components/Dashboard/Dashboard.tsx new file mode 100644 index 00000000..ac97bb6e --- /dev/null +++ b/src/features/planSimulation/components/Dashboard/Dashboard.tsx @@ -0,0 +1,137 @@ +import style from './Dashboard.module.css'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { DoghnutChart } from '../../../location/components/doughnutChart/DoghnutChart'; +import { faUsers, faSitemap, faHouseUser, faDiceD20 } from '@fortawesome/free-solid-svg-icons'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { useState } from 'react'; +import { ChartSwitch } from './ChartSwitch/ChartSwitch'; +import StackedBarChart from '../../../location/components/StackedBarChart/StackedBarChart'; +import GaugeChart from '../../../location/components/GaugeChart/GaugeChart'; + +library.add(faUsers, faSitemap, faHouseUser, faDiceD20); + +interface DashboardProps { + chartData: Record; + chartLabels: string[]; + totals: Record; + polulationChart?: boolean; + buildingsChart?: boolean; + targetAreaChart?: boolean; + structures?: number; + locationReport?: any; +} + +function Dashboard({ + locationReport, + chartData, + chartLabels, + totals, + polulationChart = true, + buildingsChart = true, + targetAreaChart = false, + structures = 0 +}: DashboardProps) { + const [chartDataType, setChartDataType] = useState<'summary' | 'male' | 'female'>('summary'); + + const handleOptionChange = (option: string) => { + // Update chart data based on the selected option + switch (option) { + case 'Summary': + setChartDataType('summary'); + break; + case 'Male': + setChartDataType('male'); + break; + case 'Female': + setChartDataType('female'); + break; + default: + setChartDataType('summary'); + } + }; + + const generateChartData = [ + { label: 'Complete', data: [locationReport?.totalComplete], backgroundColor: '#008000' }, + { label: 'Incomplete', data: [locationReport?.totalIncomplete], backgroundColor: '#cd1c18' }, + { label: 'Not Visited', data: [locationReport?.totalNotVisited], backgroundColor: '#FFE066' } + ]; + + return ( +
+ {polulationChart && ( +
+
+ +

Population

+
+
+
+ +
+
+

Total:

+

{totals[chartDataType]?.toLocaleString()}

+
+
+ +
+ )} + {targetAreaChart && Object.keys(locationReport).length > 0 && ( + <> + + + + + )} + + {!!(buildingsChart && structures && structures > 0) && ( +
+
+ +

Structures

+
+

{structures}

+
+ )} +
+ ); +} + +export default Dashboard; diff --git a/src/features/planSimulation/components/Dataset/Dataset.module.css b/src/features/planSimulation/components/Dataset/Dataset.module.css new file mode 100644 index 00000000..b2f35854 --- /dev/null +++ b/src/features/planSimulation/components/Dataset/Dataset.module.css @@ -0,0 +1,3 @@ +.filterWrapper { + padding-left: 1rem; +} diff --git a/src/features/planSimulation/components/Dataset/Dataset.tsx b/src/features/planSimulation/components/Dataset/Dataset.tsx new file mode 100644 index 00000000..5f4e3d48 --- /dev/null +++ b/src/features/planSimulation/components/Dataset/Dataset.tsx @@ -0,0 +1,39 @@ +import { Color } from 'react-color-palette'; +import { DualRangeSlider } from '../../../../components/DualRangeSlider/DualRangeSlider'; +import SwitchButton from '../../../../components/SwitchButton/SwitchButton'; +import { useState } from 'react'; + +interface DatasetProps { + dataset: { + name: string; + color: Color; + }; + color?: Color; +} + +function Dataset({ dataset, color }: DatasetProps) { + const [checked, setChecked] = useState(false); + + return ( + <> + setChecked(!checked)} + colorOne={color && color.hex} + /> + + + ); +} + +export default Dataset; diff --git a/src/features/planSimulation/components/Dataset/Filter/Filter.module.css b/src/features/planSimulation/components/Dataset/Filter/Filter.module.css new file mode 100644 index 00000000..e69de29b diff --git a/src/features/planSimulation/components/Dataset/Filter/Filter.tsx b/src/features/planSimulation/components/Dataset/Filter/Filter.tsx new file mode 100644 index 00000000..070b94f6 --- /dev/null +++ b/src/features/planSimulation/components/Dataset/Filter/Filter.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import SwitchButton from '../../../../../components/SwitchButton/SwitchButton'; +import { DualRangeSlider } from '../../../../../components/DualRangeSlider/DualRangeSlider'; + +function Filter({ dataset, color }: any) { + const [checked, setChecked] = React.useState(false); + + return ( + <> + setChecked(!checked)} + colorOne={color.hex} + /> + + + ); +} + +export default Filter; diff --git a/src/features/planSimulation/components/Hierarchy/Hierarchy.module.css b/src/features/planSimulation/components/Hierarchy/Hierarchy.module.css new file mode 100644 index 00000000..ec8465dd --- /dev/null +++ b/src/features/planSimulation/components/Hierarchy/Hierarchy.module.css @@ -0,0 +1,78 @@ +.hierarchy { + margin-bottom: 10px; +} + +.itemHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin: 5px 0; + padding: 3px; + cursor: pointer; + border-radius: 0.375rem; + transition: all 0.2s; +} + +.itemHeader:hover { + background-color: #f3f3f3; +} + +.expandButton { + margin-left: 10px; + cursor: pointer; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 0.375rem; + transition: all 0.2s; +} + +.icon { + width: 0.9rem; + height: 0.9rem; +} + +.removeButton { + width: 22px; + height: 22px; + cursor: pointer; + opacity: 0.5; +} + +.expandButton:hover { + background-color: #ffffff; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.children { + margin-left: 15px; + border-left: 2px solid #cccccc63; + padding-left: 10px; + overflow: hidden; + transition: all 0.2s; +} +.search { + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + background-color: white; + padding: 5px 10px; + border: 1px solid #0000001a; + border-radius: 0.375rem; +} + +.searchBarWrapper { + width: 22px; + height: 22px; +} + +.searchInput { + width: 100%; + padding: 5px; + border: none; +} diff --git a/src/features/planSimulation/components/Hierarchy/Hierarchy.tsx b/src/features/planSimulation/components/Hierarchy/Hierarchy.tsx new file mode 100644 index 00000000..30b7bf90 --- /dev/null +++ b/src/features/planSimulation/components/Hierarchy/Hierarchy.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from 'react'; +import styles from './Hierarchy.module.css'; +import search from '../../../../assets/svgs/search.svg'; +import remove from '../../../../assets/svgs/remove.svg'; +import HierarchyItem from './HierarchyItem/HierarchyItem'; +import { usePolygonContext } from '../../../../contexts/PolygonContext'; +export interface HierarchyItemProps { + identifier: string; + properties: { + assigned: false; + childrenNumber: number; + geographicLevel: string; + name: string; + parentIdentifier: string; + simulationSearchResult: boolean; + }; + isOpen?: boolean; + children?: HierarchyItemProps[]; +} + +interface HierarchyProps { + // data: HierarchyItemProps[]; + clickHandler: (id: string) => void; +} + +function Hierarchy({ clickHandler }: HierarchyProps) { + const { state } = usePolygonContext(); + + const data = state.polygons; + + const [filteredData, setFilteredData] = useState(data); + + const [searchTerm, setSearchTerm] = useState(''); + + const toggleExpanded = (id: string) => { + const updateIsOpen = (items: HierarchyItemProps[]): HierarchyItemProps[] => { + return items.map(item => { + if (item.identifier === id) { + return { ...item, isOpen: !item.isOpen }; + } + if (item.children) { + return { ...item, children: updateIsOpen(item.children) }; + } + return item; + }); + }; + + setFilteredData(prevData => updateIsOpen(prevData)); + }; + + useMemo(() => { + setFilteredData(data); + }, [data]); + + useMemo(() => { + if (state.selected?.parentIdentifier) { + setFilteredData(prevData => { + const updateData = (items: HierarchyItemProps[]): HierarchyItemProps[] => { + return items.map(item => { + if (item.identifier === state.selected?.parentIdentifier) { + return { ...item, isOpen: true }; + } + if (item.children) { + return { ...item, children: updateData(item.children) }; + } + return item; + }); + }; + + return updateData(prevData); + }); + } + }, [state.selected?.parentIdentifier]); + + const filterData = (items: HierarchyItemProps[], term: string): HierarchyItemProps[] => { + return items + .map(item => { + const itemName = item.properties.name.toLowerCase(); + const searchTerm = term.toLowerCase(); + + // Check if the item matches the search term + if (itemName.startsWith(searchTerm)) { + return { + ...item, + isOpen: true, // Expand this item + children: item.children ? filterData(item.children, term) : undefined + }; + } + + // Check if any children match the search term + if (item.children) { + const filteredChildren = filterData(item.children, term); + if (filteredChildren.length > 0) { + return { + ...item, + isOpen: true, // Expand this item since a child matches + children: filteredChildren + }; + } + } + + return null; + }) + .filter(Boolean) as HierarchyItemProps[]; + }; + + const handleSearch = (term: string) => { + setSearchTerm(term); + if (term === '') { + setFilteredData(data.map((item: any) => ({ ...item, isOpen: false }))); // Collapse all items + } else { + setFilteredData(filterData(data, term)); + } + }; + + return ( +
+
+ handleSearch(e.target.value)} + /> +
+ {searchTerm.length > 0 ? ( + Remove handleSearch('')} /> + ) : ( + Search + )} +
+
+ {filteredData.map(item => ( + + ))} +
+ ); +} + +export default Hierarchy; diff --git a/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.module.css b/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.module.css new file mode 100644 index 00000000..85748886 --- /dev/null +++ b/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.module.css @@ -0,0 +1,5 @@ +.selected { + background-color: #f0f0f0; + border-left: 3px solid rgb(3, 166, 13); + padding-left: 10px; +} diff --git a/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.tsx b/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.tsx new file mode 100644 index 00000000..df99cf64 --- /dev/null +++ b/src/features/planSimulation/components/Hierarchy/HierarchyItem/HierarchyItem.tsx @@ -0,0 +1,74 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import styles from '../Hierarchy.module.css'; +import { HierarchyItemProps } from '../Hierarchy'; +import itemStyle from './HierarchyItem.module.css'; + +// CONTEXT +import { usePolygonContext } from '../../../../../contexts/PolygonContext'; +import { useMemo, useRef, useState } from 'react'; + +const HierarchyItem = ({ + item, + toggleExpanded, + clickHandler +}: { + item: HierarchyItemProps; + toggleExpanded: (id: string) => void; + clickHandler: (id: string) => void; +}) => { + const { isOpen, children, properties, identifier } = item; + const { state, dispatch } = usePolygonContext(); + const itemRef = useRef(null); + + const singleSelect = state.selected; + + const [selectedState, setSelectedState] = useState(singleSelect); + + useMemo(() => { + setSelectedState(singleSelect); + }, [singleSelect]); + + if (selectedState?.id === identifier && itemRef.current) { + itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + return ( +
+
{ + clickHandler(identifier); + }} + > + {properties.name} + {selectedState?.id === identifier && } + {children && children.length > 0 && ( +
{ + e.stopPropagation(); + toggleExpanded(identifier); + }} + className={styles.expandButton} + > + +
+ )} +
+ {isOpen && children && ( +
+ {children.map(child => ( + + ))} +
+ )} +
+ ); +}; + +export default HierarchyItem; diff --git a/src/features/planSimulation/components/MultiselectList/MultiselectList.module.css b/src/features/planSimulation/components/MultiselectList/MultiselectList.module.css new file mode 100644 index 00000000..cb5b00e3 --- /dev/null +++ b/src/features/planSimulation/components/MultiselectList/MultiselectList.module.css @@ -0,0 +1,58 @@ +.multiselectItem { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 5px; +} + +.multiselectItem_info { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + gap: 0.5rem; +} + +.multiselectItem_info p { + margin: 0; +} + +.selectedIcon { + filter: brightness(0) saturate(100%) invert(21%) sepia(91%) saturate(7190%) hue-rotate(358deg) brightness(109%) + contrast(115%); + width: 10px; + height: 10px; + margin-right: 0.2rem; +} + +.selectedPolygonUl { + display: flex; + list-style-type: none; + padding: 0; + margin: 0; +} + +.removeLocationButton { + border: none; + padding: 0 0.5rem; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + border-radius: 7px; + background: transparent; +} + +.removeLocationButton img { + width: 17px; + height: 17px; + opacity: 0.7; + transition: all 0.2s; +} + +.removeLocationButton:hover img { + opacity: 1; +} diff --git a/src/features/planSimulation/components/MultiselectList/MultiselectList.tsx b/src/features/planSimulation/components/MultiselectList/MultiselectList.tsx new file mode 100644 index 00000000..fb8dc332 --- /dev/null +++ b/src/features/planSimulation/components/MultiselectList/MultiselectList.tsx @@ -0,0 +1,24 @@ +import task from '../../../../assets/svgs/task.svg'; +import Delete from '../../../../assets/svgs/trash-bin.svg'; +import styles from './MultiselectList.module.css'; +import { SelectedPolygon, usePolygonContext } from '../../../../contexts/PolygonContext'; + +function MultiselectList({ selectedPolygon }: { selectedPolygon: any }) { + const { dispatch } = usePolygonContext(); + + return ( +
+
+ selected Polygon +

{selectedPolygon.properties.name}

+
+ +
+ ); +} +export default MultiselectList; diff --git a/src/features/planSimulation/components/Simulation.module.css b/src/features/planSimulation/components/Simulation.module.css new file mode 100644 index 00000000..6449a245 --- /dev/null +++ b/src/features/planSimulation/components/Simulation.module.css @@ -0,0 +1,119 @@ +.planSimulationForm { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.customReactSelect { + font-size: 0.8rem; +} + +/* Filter locations by a Parent Location */ + +.customSwitchWrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + grid-gap: 0; + gap: 1rem; + font-size: 1rem; + border: 1px solid #0000001a; + border-radius: 5px; + padding: 1rem; +} + +.filterLoactionsWrapper { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.customSwitchForm { +} + +.filterLoactionsWrapper__infoContainer { +} + +.filterLoactionsWrapper__label { + color: black; + margin: 0; +} + +.filterLoactionsWrapper__subLabel { + color: #73737398; + margin: 0; + font-size: 0.8rem; +} + +.loadInactiveWrapper { +} + +.parentLocationSelects { + border: 1px solid #0000001a; + border-radius: 5px; + padding: 1rem; + font-size: 0.8rem; +} + +.parentLocationSelects select { + font-size: 0.8rem; +} + +.formSubmitWrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; +} + +.formControlsWrapper label { + margin: 0; +} + +.formControlsWrapper { + width: 100%; + display: flex; + gap: 1rem; + align-items: center; +} + +.searchButton { + width: 100%; + padding-top: 1rem; + padding-bottom: 1rem; +} + +.select { + width: 100%; +} + +.WrapperDasasetsButton { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + background-color: #ffffff; + border: 1px solid #0000001a; + border-radius: 0.375rem; + padding: 0.5rem; + margin-bottom: 0.5rem; +} + +.dasasetsButton { + background-color: #f9f9f9; + border-radius: 0.375rem; + border: 1px solid #0000001a; + text-align: center; + text-decoration: none; + width: 100%; + color: black; + padding: 0.5rem; + font-weight: 500; + display: inline-block; + margin: 0; + transition: background-color 0.3s; +} + +.dasasetsButton:hover { + background-color: #f0f0f0; +} diff --git a/src/features/planSimulation/components/Simulation.tsx b/src/features/planSimulation/components/Simulation.tsx index 4dccc2fd..2da351b9 100644 --- a/src/features/planSimulation/components/Simulation.tsx +++ b/src/features/planSimulation/components/Simulation.tsx @@ -1,6 +1,5 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Col, Container, Form, Modal, OverlayTrigger, Row, Spinner, Tooltip } from 'react-bootstrap'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Col, Container, Form, Modal, Row } from 'react-bootstrap'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -9,13 +8,14 @@ import { ActionDialog } from '../../../components/Dialogs'; import { useWindowResize } from '../../../hooks/useWindowResize'; import { getGeneratedLocationHierarchyList, getLocationHierarchyList } from '../../location/api'; import { LocationHierarchyModel } from '../../location/providers/types'; -import { evaluate, isNumeric } from 'mathjs'; +import { electronMassDependencies, evaluate, isNumeric } from 'mathjs'; +import styles from './Simulation.module.css'; + import { getDataAssociatedEntityTags, getEntityList, getEventBasedEntityTags, getFullLocationsSSE, - getLocationList, getLocationsSSE, submitSimulationRequest, updateSimulationRequest @@ -34,15 +34,12 @@ import { RevealFeature, SearchLocationProperties } from '../providers/types'; -import FormField from './FormField/FormField'; -import MultiFormField from './FormField/MultiFormField'; import SimulationModal from './SimulationModal'; - -import Select, { MultiValue, SingleValue } from 'react-select'; +import { MultiValue, SingleValue } from 'react-select'; +import Select from 'react-select'; import PeopleDetailsModal from './PeopleDetailsModal'; -import { bbox, Feature, MultiPolygon, Point, Polygon } from '@turf/turf'; +import { bbox, Geometry, polygon } from '@turf/turf'; import { LngLatBounds, Map as MapBoxMap } from 'mapbox-gl'; - import SimulationResultExpandingTable from '../../../components/Table/SimulationResultExpandingTable'; import DownloadSimulationResultsModal from './modals/DownloadSimulationResultsModal'; import UploadSimulationData from './modals/UploadSimulationData'; @@ -56,6 +53,47 @@ import { Color } from 'react-color-palette'; import { hex } from 'color-convert'; import { REVEAL_SIMULATION_EDIT } from '../../../constants'; import AuthorizedElement from '../../../components/AuthorizedElement'; +import { Drawer } from '../../location/components/drawer/Drawer'; +import Accordion from '../../location/components/accordion/Accordion'; +import { faUsers, faSitemap, faHouseUser, faDiceD20 } from '@fortawesome/free-solid-svg-icons'; +import { library } from '@fortawesome/fontawesome-svg-core'; + +import Dashboard from '../components/Dashboard/Dashboard'; + +import DrawerButton from '../../../components/DrawerButton/DrawerButton'; + +import { CustomPopup } from '../../../components/CustomPopup/CustomPopup'; +import DatasetsAccordion from '../../location/components/DatasetsAccordion/DatasetsAccordion'; + +import AddTargetAreaForm from './SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm'; +import AddDatasetForm from './SimulationMapView/components/AddDatasetForm/AddDatasetForm'; + +import CampaignTotalsAccordion from './SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion'; + +import { + getDefaultHierarchyData, + getHierarchy, + getHierarchyPolygon, + getPlanInfo +} from './SimulationMapView/api/hierarchyAPI'; +import Hierarchy from './Hierarchy/Hierarchy'; + +// CONTEXT +import { usePolygonContext } from '../../../contexts/PolygonContext'; +import { + AddDatasetResponse, + addSearchRequest, + DataSetList, + deleteDataset, + filterDatasets, + getLocationPolygonsWithDatasets, + getSimulationData, + LocationData, + SimulationDatasetRequest +} from './SimulationMapView/api/datasetsAPI'; +import { assignLocationsToPlan } from '../../assignment/api'; + +library.add(faUsers, faSitemap, faHouseUser, faDiceD20); interface SubmitValue { fieldIdentifier: string; @@ -114,9 +152,22 @@ export interface Children { childrenList: string[]; } +export interface Polygondata { + polygonData: any; + childrenLoaded: boolean; +} + +interface PolygonsState { + [key: string]: Polygondata; +} + +// const extractPolygonsFromPolysWithData = (polygonsWithData?: PolygonsState) => { +// return polygonsWithData ? Object.values(polygonsWithData!).map((polygon: Polygondata) => polygon.polygonData) : []; +// }; + const Simulation = () => { const { t } = useTranslation(); - const [showModal, setShowModal] = useState(false); + // const [showModal, setShowModal] = useState(false); const [showDetails, setShowDetails] = useState(false); const [showResult, setShowResult] = useState(false); const [combinedHierarchyList, setCombinedHierarchyList] = useState(); @@ -125,7 +176,7 @@ const Simulation = () => { const [selectedEntityConditionList, setSelectedEntityConditionList] = useState([]); const divRef = useRef(null); const divHeight = useWindowResize(divRef.current); - const [mapFullScreen, setMapFullScreen] = useState(false); + const [mapFullScreen, setMapFullScreen] = useState(true); const [mapData, setMapData] = useState(); const [parentMapData, setParentMapData] = useState(); const [mapDataLoad, setMapDataLoad] = useState({ @@ -137,8 +188,8 @@ const Simulation = () => { source: undefined }); const [parentMapDataLoad, setParentMapDataLoad] = useState(); - const [nodeList, setNodeList] = useState([]); - const [completeGeographicList, setCompleteGeographicList] = useState([]); + // const [nodeList, setNodeList] = useState([]); + // const [completeGeographicList, setCompleteGeographicList] = useState([]); const [locationList, setLocationList] = useState([]); const [selectedHierarchy, setSelectedHierarchy] = useState(); const [selectedLocation, setSelectedLocation] = useState>(); @@ -148,7 +199,7 @@ const Simulation = () => { const [entityTags, setEntityTags] = useState([]); const [entityTagsOriginal, setEntityTagsOriginal] = useState([]); const [showSummaryModal, setShowSummaryModal] = useState(false); - const [highestLocations, setHighestLocations] = useState[]>(); + const [highestLocations, setHighestLocations] = useState(); const [summary, setSummary] = useState({}); const [selectedMapData, setSelectedMapData] = useState(); const [showCountResponseModal, setShowCountResponseModal] = useState(false); @@ -192,6 +243,226 @@ const Simulation = () => { const [tooLargeOrSmall, setTooLargeOrSmall] = useState(0); const [omitLayers, setOmitLayers] = useState(false); + const [leftOpen, setLeftOpen] = useState(false); + const [rightOpen, setRightOpen] = useState(false); + + const [showModal, setShowModal] = useState(false); + + const [openCustomModal, setOpenCustomModal] = useState(); + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + // const [geometry, setGeometry] = useState | null>(null); + const [geometry, setGeometry] = useState(); + const [currentLocationId, setCurrentLocationId] = useState(); + // const [polygons, setPolygons] = useState([]); + const [polygonsWithData, setPolygonsWithData] = useState(); + + const [selectedLocationChildren, setSelectedLocationChildren] = useState([]); + + const [datasetList, setDatasetList] = useState([]); + const [includeGeometry, setIncludeGeometry] = useState(true); + + const [chartData, setChartData] = useState>({}); + const [totals, setTotals] = useState>({}); + const [labels, setLabels] = useState([]); + const [nodeOrderListVisible, setNodeOrderListVisible] = useState(false); + const [showDatasetsAgainstParentLevel, setShowDatasetsAgainstParentLevel] = useState(false); + const [selectedParentLevel, setSelectedParentLevel] = useState>(); + const [showingParentLevelsMenu, setShowingParentLevelsMenu] = useState(false); + const [numberOfStructures, setNumberOfStructures] = useState(0); + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + + const { dispatch } = usePolygonContext(); + const { state } = usePolygonContext(); + + const fetchSimulationAndData = async () => { + const simulationIdentifier = await fetchPlanInfo(); + + try { + const simulationData = await getSimulationData(simulationIdentifier); + dispatch({ type: 'SET_NEW_DATASETS', payload: simulationData.datasets }); + dispatch({ type: 'SET_SIMULATION_ID', payload: simulationData.identifier }); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + } catch (error) { + console.error('Failed to fetch simulation:', error); + } + }; + + const handleAddDataset = (datasetResponse: AddDatasetResponse) => { + const dataset = { + identifier: datasetResponse.datasetId, + name: datasetResponse.datasetName, + hexColor: datasetResponse.hexColor, + lineWidth: datasetResponse.lineWidth, + borderColor: datasetResponse.borderColor + }; + + dispatch({ type: 'ADD_DATASET', payload: dataset }); + + //! LOOP LOCATIONS WITH METADA AND ATTACH DATASET DATA TO LOADED POLYGONS + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + + Object.entries(datasetResponse.locationWithMetadata).forEach(([locationId, metadata]) => { + if (updatedPolygons[locationId]) { + const existingMetadata = updatedPolygons[locationId].polygonData.properties.metadata || []; + updatedPolygons[locationId].polygonData.properties.metadata = Array.from( + new Set([...existingMetadata, metadata]) + ); + } + }); + return updatedPolygons; + }); + }; + + const fetchHierarchy = async () => { + const hierarchyData = await getHierarchy(); + try { + setHighestLocations(hierarchyData); + dispatch({ type: 'SET_HIERARCHY', payload: hierarchyData }); + } catch (error) { + console.error('Failed to fetch hierarchy:', error); + } + }; + + const fetchPlanInfo = async () => { + try { + const planInfo = await getPlanInfo(); + dispatch({ type: 'SET_PLANID', payload: planInfo.identifier }); + return planInfo.identifier; + } catch (error) { + console.error('Failed to fetch plan info:', error); + } + }; + + const fetchDefaultHierarchyData = async () => { + const hierarchyData = await getDefaultHierarchyData(); + try { + dispatch({ type: 'SET_DEFAULT_HIERARCHY_DATA', payload: hierarchyData }); + } catch (error) { + console.error('Failed to fetch hierarchy data:', error); + } + }; + + useMemo(() => { + setDatasetList(state.datasets); + }, [state.datasets]); + + //! UPDATE DATASETS LIST + const updateDatasetHandler = async (newDatasetList: string) => { + dispatch({ type: 'SET_DATASET', payload: newDatasetList }); + }; + + const removeDatasetHandler = async (datasetId: string) => { + deleteDataset({ + simulationId: state.simulationId, + datasetId + }); + dispatch({ type: 'DELETE_DATASET', payload: datasetId }); + //! remove dataset update metadata + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + + Object.entries(updatedPolygons).forEach(([locationId, polygonData]: any) => { + const updatedMetadata = polygonData.polygonData.properties.metadata.filter( + (metadata: any) => metadata.datasetId !== datasetId + ); + updatedPolygons[locationId].polygonData.properties.metadata = updatedMetadata; + }); + + return updatedPolygons; + }); + }; + + // we are updating selectedLocationChildren whenever an assignment happens, + // because assigned flag on these locations is not updated (it is still the one we got on location fetch) + useEffect(() => { + setSelectedLocationChildren(prev => + prev.map(obj => ({ + ...obj, + properties: { + ...obj.properties, + assigned: state.assingedLocations[obj.identifier] + } + })) + ); + }, [state.assingedLocations]); + + useEffect(() => { + if ( + currentLocationId && + polygonsWithData && + polygonsWithData[currentLocationId] && + !showDatasetsAgainstParentLevel + ) { + const children = Object.values(polygonsWithData) + .map((polygon: any) => polygon.polygonData) + .filter((polygon: any) => polygon.properties.parentIdentifier === currentLocationId); + + setSelectedLocationChildren(children); + + // when locations loaded, we are setting their assigned flag values as default values in assignment map + // this way, state.assignedLocations is our single source of truth + const assignedMap = children.reduce( + (map, obj) => { + return { + ...map, + [obj.identifier]: map[obj.identifier] ?? obj.properties.assigned + }; + }, + { ...state.assingedLocations } + ); + dispatch({ type: 'SET_ASSIGNED', payload: assignedMap }); + + const selectedLocation = polygonsWithData[currentLocationId].polygonData; + + console.log('418 selectedLocation', selectedLocation?.properties.name); + + if (selectedLocation && !showDatasetsAgainstParentLevel) { + setGeometry(selectedLocation); + setToLocation(JSON.parse(JSON.stringify(bbox(selectedLocation.geometry)))); + const populationData = transformPopulationData(selectedLocation?.properties?.population); + setNumberOfStructures(selectedLocation?.properties?.numberOfStructures); + if (populationData !== null) { + setChartData(populationData?.chartData); + setLabels(populationData?.labels); + setTotals(populationData?.totals); + } + } + } + }, [currentLocationId, polygonsWithData, showDatasetsAgainstParentLevel]); + + useEffect(() => { + let populationData: any; + if (state.selected) { + populationData = state.selected.population + ? transformPopulationData(JSON.parse(state.selected.population)) + : null; + setNumberOfStructures(state.selected?.numberOfStructures); + if (populationData !== null) { + setChartData(populationData.chartData); + setLabels(populationData?.labels); + setTotals(populationData?.totals); + } + } else if (!state.selected && currentLocationId) { + const selectedLocation = polygonsWithData[currentLocationId].polygonData; + const populationData = transformPopulationData(selectedLocation?.properties?.population); + setNumberOfStructures(selectedLocation?.properties?.numberOfStructures); + if (populationData !== null) { + setChartData(populationData.chartData); + setLabels(populationData?.labels); + setTotals(populationData?.totals); + } + } + }, [state.selected, showDatasetsAgainstParentLevel]); + + useEffect(() => { + fetchHierarchy(); + fetchSimulationAndData(); + fetchDefaultHierarchyData(); + }, []); + + // AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + useEffect(() => { Promise.all([ getLocationHierarchyList(50, 0, true), @@ -1046,16 +1317,16 @@ const Simulation = () => { } }); - if (mapData.parents) { - let highestLocations: any[] = Object.keys(mapData.parents) - .filter( - key => - mapData.parents[key].properties !== null && - mapData.parents[key].properties?.geographicLevelNodeNumber === min - ) - .map(key => mapData.parents[key]); - setHighestLocations(highestLocations); - } + // if (mapData.parents) { + // let highestLocations: any[] = Object.keys(mapData.parents) + // .filter( + // key => + // mapData.parents[key].properties !== null && + // mapData.parents[key].properties?.geographicLevelNodeNumber === min + // ) + // .map(key => mapData.parents[key]); + // // setHighestLocations(highestLocations); + // } } } }, [mapData, resultsLoaded, parentsLoaded, getLocationHierarchyFromLowestLocation, markedLocations]); @@ -1082,32 +1353,100 @@ const Simulation = () => { } }, [selectedHierarchy]); - const loadLocationHandler = (locationId: string) => { - let feature = mapData?.features[locationId] || mapData?.parents[locationId]; - - if (feature && feature.geometry) { - setToLocation(JSON.parse(JSON.stringify(bbox(feature)))); + const checkifChildrenLoaded = (polygonsWithData: any, selectedLocationId: any) => { + if (polygonsWithData?.[selectedLocationId]?.childrenLoaded === undefined) { + return true; + } else if (polygonsWithData?.[selectedLocationId]?.childrenLoaded === true) { + return false; } else { + return true; } }; - const showDetailsClickHandler = (locationId: string) => { - let feature = mapData?.parents[locationId]; - //create deep copy of bounds object for triggering bounds event every time - if (feature) { - let val: any = feature; - let valProps: SearchLocationProperties = { - bounds: feature.geometry ? (bbox(val.geometry) as any) : undefined, - identifier: val.identifier, - metadata: val.properties?.metadata, - name: val.properties?.name, - persons: [] + useEffect(() => { + fetchHierarchy(); + fetchSimulationAndData(); + }, []); + + //! LOADING POLYGONS ON DEMAND + const loadLocationHandler = async (locationId: string) => { + setShowDatasetsAgainstParentLevel(false); + setSelectedParentLevel(null); + setNodeOrderListVisible(false); + + setCurrentLocationId(locationId); + if (state.datasets.length === 0 && polygonsWithData?.[locationId]?.childrenLoaded) { + setIncludeGeometry(false); + + const k = Object.values(polygonsWithData) + .map((polygon: any) => polygon.polygonData) + .filter(p => p.properties.parentIdentifier === locationId); + + setSelectedLocationChildren(k); + + const selectedLocation = polygonsWithData?.[locationId]?.polygonData; + if (selectedLocation) { + setGeometry(selectedLocation); + setToLocation(JSON.parse(JSON.stringify(bbox(selectedLocation.geometry)))); + } + } else { + const includeGeometry: boolean = checkifChildrenLoaded(polygonsWithData, locationId); + + let configObj: LocationData = { + datasetsIds: datasetList.map(dataset => dataset.identifier), + includeGeometry: includeGeometry, + parentLocationId: locationId, //current location identifier + simulationId: state.simulationId }; - setSelectedRow(valProps); - setShowDetails(true); + + const polygonsWithDatasets = await getLocationPolygonsWithDatasets(configObj); + if (!polygonsWithDatasets || polygonsWithDatasets.length === 0) { + toast.error('Cannot get results. Please try again.'); + return; + } + + if (includeGeometry) { + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + polygonsWithDatasets.forEach((location: any) => { + updatedPolygons[location.identifier] = { + polygonData: location, + childrenLoaded: location.identifier === locationId + }; + }); + + return updatedPolygons; + }); + } else { + setPolygonsWithData((prev: any) => { + const updatedPolygons = { ...prev }; + polygonsWithDatasets.forEach((location: any) => { + updatedPolygons[location.identifier].polygonData.properties.metadata = location.properties.metadata; + }); + + return updatedPolygons; + }); + } } }; + // const showDetailsClickHandler = (locationId: string) => { + // let feature = mapData?.parents[locationId]; + // //create deep copy of bounds object for triggering bounds event every time + // if (feature) { + // let val: any = feature; + // let valProps: SearchLocationProperties = { + // bounds: feature.geometry ? (bbox(val.geometry) as any) : undefined, + // identifier: val.identifier, + // metadata: val.properties?.metadata, + // name: val.properties?.name, + // persons: [] + // }; + // setSelectedRow(valProps); + // setShowDetails(true); + // } + // }; + const processChildren = useCallback( (mapDataClone: any) => { let geoLevel: string = mapDataClone.properties.geographicLevel; @@ -1162,430 +1501,249 @@ const Simulation = () => { setShowResult(true); }; - const conditionalRender = (el: EntityTag, index: number) => { - if (el.more && el.more.length) { - return ( - { - if (range) { - el.more.splice(1); - } else { - unregister((el.tag + index + 'range') as any); - el.more.splice(i, 1); - } - setSelectedEntityConditionList([...selectedEntityConditionList]); - }} - /> + // const conditionalRender = (el: EntityTag, index: number) => { + // if (el.more && el.more.length) { + // return ( + // { + // if (range) { + // el.more.splice(1); + // } else { + // unregister((el.tag + index + 'range') as any); + // el.more.splice(i, 1); + // } + // setSelectedEntityConditionList([...selectedEntityConditionList]); + // }} + // /> + // ); + // } + // return ; + // }; + + // const selectStyles = { + // dropdownIndicator: (baseStyles: object) => ({ + // ...baseStyles, + // scale: '0.8' + // }), + // clearIndicator: (baseStyles: object) => ({ + // ...baseStyles, + // scale: '0.8' + // }) + // }; + + // const convertColor = (color: any) => { + // if (!color) return; + // let hexValue = color.toString(); + // let rgbArr = hex.rgb(hexValue); + // let hsvArr = hex.hsv(hexValue); + + // let convertedColor = { + // hex: '#009900', + // rgb: { r: rgbArr[0], g: rgbArr[1], b: rgbArr[2] }, + // hsv: { h: hsvArr[0], s: hsvArr[1], v: hsvArr[2] } + // }; + + // return convertedColor as Color; + // }; + + const handleRemoveTargetArea = (id: string) => { + const assignedAreas = state.targetAreas?.flatMap((ta: any) => [...ta.ancestry, ta.identifier]) || []; + const targetArea = state.targetAreas?.find(ta => ta.identifier === id); + const toExcludeSet = new Set([...targetArea.ancestry, id]); + const filtered = assignedAreas.filter(item => !toExcludeSet.has(item)); + assignLocationsToPlan(state.planid, filtered).then(async () => { + // update assignment map + dispatch({ type: 'SET_ASSIGNED', payload: { ...state.assingedLocations, [id]: false } }); + // refetch target areas, so the map updates + const simulationData = await getSimulationData(state.planid); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + dispatch({ type: 'CLEAR_SELECTION' }); + }); + }; + + const campaignTotals = [ + { + label: 'Target Areas', + total: state.targetAreas.length, + targetAreasList: state.targetAreas, + remove: handleRemoveTargetArea + }, + { + label: 'Total Population', + total: Math.round(state.targetAreas?.reduce((a, b) => a + b?.properties?.population?.sum, 0)) || 0, + targetAreasList: state.targetAreas, + type: 'population' + } + ]; + + const handleDatasetsButtonClick = () => { + setNodeOrderListVisible(!nodeOrderListVisible); + }; + + const filterDatasetsErrorHandler = () => { + //toast("An error occured. Cannot load requested data.") + //TODO + }; + const filterDatasetsOpenHandler = () => { + //TODO + }; + const filterDatasetsCloseHandler = () => { + //TODO + }; + + const filterDatasetsMessageHandler = (message: any) => { + const res: any = JSON.parse(message.data); + setSelectedLocationChildren(prev => { + return [...prev, ...res]; + }); + }; + + const handleParentSelectionChange = async (option: SingleValue<{ value: string; label: string }>) => { + setSelectedParentLevel(option); + if (option != null && option.value !== '') { + const searchRequest: SimulationDatasetRequest = { + simulationId: state.simulationId, + parentAdminLevel: option.value + }; + const searchId = await addSearchRequest(searchRequest); + setSelectedLocationChildren([]); + setShowDatasetsAgainstParentLevel(true); + filterDatasets( + searchId, + filterDatasetsMessageHandler, + filterDatasetsCloseHandler, + filterDatasetsOpenHandler, + filterDatasetsErrorHandler ); } - return ; }; return ( <> - - {!mapFullScreen && ( - -
- - - - Use Layers}> - Omit Layers: - - - - setOmitLayers(!omitLayers)} - /> - - - - - - - {t('simulationPage.hierarchy')}: - - - { - const selectedHierarchy = combinedHierarchyList?.find(el => el.identifier === e.target.value); - if (selectedHierarchy) { - setSelectedHierarchy(selectedHierarchy); - setNodeList(selectedHierarchy.nodeOrder.filter(el => el !== 'structure')); - setCompleteGeographicList(selectedHierarchy.nodeOrder); - } else { - setSelectedHierarchy(undefined); - setNodeList([]); - setSelectedLocation(null); - setCompleteGeographicList([]); - } - }} - > - - {combinedHierarchyList?.map(el => ( - - ))} - - - - - - - -
- Filter locations by a Parent Location{' '} - - (Search results will be locations within this parent location) - -
- - - - {t('simulationPage.selectParentToSearchWithin')} - } - > - {t('simulationPage.geographicLevel')}: - - - - { - if (e.target.value && selectedHierarchy && selectedHierarchy.type) { - getLocationList( - selectedHierarchy.identifier, - selectedHierarchy.type, - e.target.value - ).then(res => { - setLocationList(res); - }); - } else { - setLocationList([]); - } - setSelectedLocation(null); - }} - > - - {nodeList.map(el => ( - - ))} - - - - - - - - - {t('simulationPage.selectParentLocationToSearchWithin')} - - } - > - {t('simulationPage.location')}: - - - - { - return { value: geo, label: geo }; - }) - .filter((geo: any) => !levelsLoaded.current.includes(geo.label))} - value={geoFilterList} - noOptionsMessage={obj => { - if (obj.inputValue === '') { - return 'Enter at least 1 char to display the results...'; - } else { - return 'No location found.'; - } - }} - placeholder={ - completeGeographicList.length > 0 - ? t('simulationPage.search') + '...' - : t('simulationPage.selectHierarchyFirst') - } - onInputChange={e => {}} - onChange={newValues => { - setGeoFilterList(newValues); - if (newValues) { - setSelectedFilterGeographicLevelList(newValues.map(value => value.label)); - } - }} - /> - - - - - - - {t('simulationPage.selectToLoadInactiveLocations')} - } - > - {t('simulationPage.loadInactiveLocations')}: - - - - setLoadParentsToggle(e.target.checked)} - /> - - - - - {loadParentsToggle && ( - - - {t('simulationPage.filterInactiveLocationsByLevel')} - } - > - {t('simulationPage.filterGeographicLevel')}: - - - +
+ + {/* {highestLocations && showResult && ( */} + {highestLocations && ( + + + {/* setOpenCustomModal(0)}>Add Operational Area + setOpenCustomModal(undefined)} hasBackdrop> + setOpenCustomModal(undefined)} /> + */} + + )} + {/* {highestLocations && showResult && ( */} + {highestLocations && ( + + {state.datasets?.length !== 0 && ( +
+ + {nodeOrderListVisible && ( + <> null + }} + placeholder={'Select Dataset'} + className={styles.select} + isClearable + options={entityTags.map(tag => ({ + value: tag.identifier, + label: tag.tag + }))} + onChange={selectedOption => { + setFormValue((prevValue: any) => ({ + ...prevValue, + tagId: selectedOption?.value || '' + })); + }} + /> + )} + +
+
+ + +
+
+ + ); +} + +export default AddDatasetForm; diff --git a/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.module.css b/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.module.css new file mode 100644 index 00000000..03e9ea6c --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.module.css @@ -0,0 +1,25 @@ +.templateDowloadWrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + width: 100%; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s; +} + +.stepControls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.step { + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.tsx b/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.tsx new file mode 100644 index 00000000..0ed4442a --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/AddTargetAreaForm/AddTargetAreaForm.tsx @@ -0,0 +1,26 @@ +import CustomStepper from '../../../../../../components/CustomStepper/CustomStepper'; +import DrawerButton from '../../../../../../components/DrawerButton/DrawerButton'; +import FileDrop from '../../../../../../components/FileDrop/FileDrop'; +import styles from './AddTargetAreaForm.module.css'; + +function AddTargetAreaForm({ onClose }: { onClose: () => void }) { + return ( + {} }} + > +
+
+ {}}>Export Template +
+
+
+ +
+
+ ); +} + +export default AddTargetAreaForm; diff --git a/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.module.css b/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.module.css new file mode 100644 index 00000000..d718feed --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.module.css @@ -0,0 +1,59 @@ +.total { + margin-left: auto; +} + +.targetAreaItem { + display: flex; + justify-content: space-between; + position: relative; + color: #444444; + margin-left: 2.2rem; +} + +.paragraph { + margin-bottom: 0; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + line-height: 35px; +} + +.hoverButton { + border: none; + cursor: pointer; + padding: 0; + background-color: transparent; + display: flex; + justify-content: center; + align-items: center; +} + +.itemDot { + position: absolute; + left: -18px; + width: 5px; + height: 5px; + top: 50%; + transform: translateY(-50%); + border-radius: 50%; + background-color: #444444; + margin-right: 0.5rem; +} + +.hoverButton img { + transition: width 0.2s; + width: 0rem; + opacity: 0.7; + height: 17px; +} + +.targetAreaItem:hover .hoverButton img { + visibility: visible; + width: 17px; + height: 17px; +} + +.targetAreaItem:hover .hoverButton { + margin-left: 0.5rem; +} diff --git a/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.tsx b/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.tsx new file mode 100644 index 00000000..68af103f --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/CampaignTotalsAccordion/CampaignTotalsAccordion.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Delete from '../../../../../../assets/svgs/trash-bin.svg'; + +import CampaignStyles from './CampaignTotalsAccordion.module.css'; +import styles from '../../../../../location/components/accordion/Accordion.module.css'; + +function CampaignTotalsAccordion({ open = false, campaignTotals }: any) { + const [isOpen, setOpen] = useState(open); + + + return ( +
+ {/* Accordion Header */} +
setOpen(!isOpen)} + style={{ position: 'relative' }} + > + {campaignTotals.total} + {campaignTotals.label} + +
+ + {/* Accordion Content */} +
+
+ {campaignTotals.targetAreasList.map((area: any, index: number) => ( +
+
+

{area?.properties?.name || ''}

+
+

{campaignTotals.type === 'population' ? Math.round(area?.properties?.population?.sum) ?? '' : '' }

+ +
+
+ ))} +
+
+
+ ); +} + +export default CampaignTotalsAccordion; diff --git a/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.module.css b/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.module.css new file mode 100644 index 00000000..1dd08e21 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.module.css @@ -0,0 +1,7 @@ +.datasetContainer { + display: flex; + flex-direction: column; + margin: 3rem; + background-color: #fff; + padding: 1rem; +} diff --git a/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.tsx b/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.tsx new file mode 100644 index 00000000..7a425daf --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/DataSetPanel/DataSetPanel.tsx @@ -0,0 +1,235 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useState } from 'react'; +import { Accordion, Form, FormGroup, Button } from 'react-bootstrap'; +import { getBackgroundStyle } from '../../SimulationMapView'; +import { + getProcessedUserDefinedLayers, + updateLayerActiveState, + handleOpenSettingsMenu, + getTransparencyValue, + getLineWidthValue +} from '../../SimulationMapViewUtils'; +import styles from './DataSetPanel.module.css'; +import { ColorPicker } from 'react-color-palette'; + +interface DataSetPanelProps { + userDefinedLayers: any[]; + setUserDefinedLayers: React.Dispatch>; + defColor: any; + setColor: React.Dispatch>; + initialLineColor: any; + showUserDefinedSettingsPanel: boolean; + setShowUserDefinedSettingsPanel: React.Dispatch>; + selectedUserDefinedLayer: any; + setSelectedUserDefinedLayer: React.Dispatch>; + userDefinedNames: any; + handleTagChange: any; + handleLineWidthChange: any; + handleTransparencyChange: any; + handleColorChange: any; + color: any; +} + +function DataSetPanel({ + userDefinedLayers, + setUserDefinedLayers, + defColor, + setColor, + initialLineColor, + showUserDefinedSettingsPanel, + setShowUserDefinedSettingsPanel, + selectedUserDefinedLayer, + setSelectedUserDefinedLayer, + userDefinedNames, + handleTagChange, + handleLineWidthChange, + handleTransparencyChange, + handleColorChange, + color +}: DataSetPanelProps) { + const [showUserDefineLayerSelector, setShowUserDefineLayerSelector] = useState(false); + + return ( +
+
+

{ + // BOOLEAN FOR OPENING AND CLOSING THE RESULT SETS + setShowUserDefineLayerSelector(!showUserDefineLayerSelector); + }} + > + ResultSets{' '} + {showUserDefineLayerSelector ? ( + + ) : ( + + )} +

+
+ + {showUserDefineLayerSelector && ( +
+ {/* REFACTORED FUNCITION THAT RETURNS THE ARRAY OF LAYERS */} + {getProcessedUserDefinedLayers(userDefinedLayers, defColor).map(layerObj => { + return ( + + + + <> +
{layerObj.key}
+
+ + + + {/* CHECK BOX LAYERS */} + <> + {layerObj?.list?.map(layer => { + return ( + alert('hello')}> + {layer.geo} +

+ } + value={layer.layer} + type="checkbox" + checked={layer.active} + onChange={e => { + setUserDefinedLayers((layerItems: any) => + updateLayerActiveState(layerItems, layer.layer, e.target.checked) + ); + }} + /> + ); + })} +
+ + {/* SETTINGS BUTTON */} + + + {!showUserDefinedSettingsPanel || selectedUserDefinedLayer?.key !== layerObj.key + ? '' + : 'Hide '} + Settings + + + +
+ + + ); + })} +
+ )} + + {/* SETTINGS PANEL WITH LINE AND OPACITY */} + + {userDefinedLayers.length > 0 && showUserDefinedSettingsPanel && selectedUserDefinedLayer && ( +
+

Settings - {selectedUserDefinedLayer.key}

+ +
+ +
+ {userDefinedNames + ?.filter((layer: { layerName: any }) => layer.layerName === selectedUserDefinedLayer.key) + .map( + (layer: { + selectedTag: string | number | readonly string[] | undefined; + layerName: any; + tagList: Iterable | ArrayLike; + }) => ( + <> + handleTagChange(e, layer.layerName)} + > + + {layer.tagList && + Array.from(layer.tagList).map((metaDataItem: any) => { + return ( + + ); + })} + + + ) + )} +
+ Opacity ({getTransparencyValue(userDefinedLayers, selectedUserDefinedLayer)}) + + + { + + + Line Control + + Line Width ({getLineWidthValue(userDefinedLayers, selectedUserDefinedLayer)}) + + + + + + + } +
+ )} +
+ ); +} + +export default DataSetPanel; diff --git a/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.module.css b/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.module.css new file mode 100644 index 00000000..25274d81 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.module.css @@ -0,0 +1,75 @@ +.legendBody { + display: flex; + flex-direction: column; + justify-content: center; + position: absolute; + z-index: 1000; + list-style: none; + background: rgba(255, 255, 255, 0.863); + border: 1px solid #ccc; + right: 50px; + bottom: 30px; + max-width: 320px; + width: 320px; + max-height: 500px; + overflow: auto; +} + +.legendName { + margin-bottom: 0; +} + +.legendTitleWrapper { + font-weight: bold; + display: flex; + align-items: center; +} + +.legendTitle { + font-size: 1.1rem; +} + +.legendList { + font-size: 0.9rem; + font-weight: bold; + margin-bottom: 0; +} + +.legendItem { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 10px; + gap: 0.75rem; +} + +.legendIcon { + width: 20px; + height: 20px; + margin-right: 7px; + opacity: 0.5; +} + +.colorBox { + width: 1rem; + height: 1rem; + border-radius: 0.25rem; +} + +.assignedItemInfo { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; +} + +.assignedItemToggle { + padding: 0.7rem; + border: 1px solid #0000001a; + border-radius: 5px; + background-color: white; + justify-content: space-between; + display: flex; + align-items: center; + list-style: none; +} diff --git a/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.tsx b/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.tsx new file mode 100644 index 00000000..f1623fe1 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/MapLegend/MapLegend.tsx @@ -0,0 +1,61 @@ +import { useMemo, useState } from 'react'; +import { usePolygonContext } from '../../../../../../contexts/PolygonContext'; +import style from './MapLegend.module.css'; +import SwitchButton from '../../../../../../components/SwitchButton/SwitchButton'; +import Accordion from '../../../../../location/components/accordion/Accordion'; +import LayerIcon from '../../../../../../assets/svgs/layers-icon.svg'; + +interface Dataset { + identifier: string; + name: string; + hexColor: string; +} + +function MapLegend({ + handleClickedSwitchOnMap, + assigned +}: { + handleClickedSwitchOnMap: (toggle: any) => void; + assigned: boolean; +}) { + const { state } = usePolygonContext(); + + const datasets = useMemo(() => state.datasets, [state.datasets]); + + return ( +
+ + map legend icon + Legend +
+ } + > +
    +
  • +
    Toggle assigned
    + handleClickedSwitchOnMap(e.target.checked)} + /> +
  • + {datasets.map((dataset: Dataset) => ( +
  • +
    + {dataset.name} +
  • + ))} +
+ +
+ ); +} + +export default MapLegend; diff --git a/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.module.css b/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.module.css new file mode 100644 index 00000000..54ab1492 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.module.css @@ -0,0 +1,15 @@ +.statisticsPanel { + right: 5%; + bottom: 3%; + position: absolute; + z-index: 2; + min-width: 15%; + width: 15%; + max-width: 30%; + background-color: #fff; + padding: 1rem; +} + +.statisticsPanelBody { + padding: 1rem; +} diff --git a/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.tsx b/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.tsx new file mode 100644 index 00000000..d4276393 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/components/StatisticsPanel/StatisticsPanel.tsx @@ -0,0 +1,108 @@ +import { isNumeric } from 'mathjs'; +import { useState, useMemo } from 'react'; +import { Tabs, Tab, Button } from 'react-bootstrap'; +import { UserDefinedLayer, UserDefinedNames } from '../../SimulationMapViewModels'; +import { EntityTag } from '../../../../providers/types'; +import { StatsLayer } from '../../../Simulation'; + +import styles from './StatisticsPanel.module.css'; + +interface StatisticsPanelProps { + userDefinedLayers: UserDefinedLayer[]; + userDefinedNames: UserDefinedNames[]; + entityTags: EntityTag[]; + stats: StatsLayer; + defaultColor?: string; // Add defaultColor as a prop +} + +function StatisticsPanel({ + userDefinedLayers, + userDefinedNames, + entityTags, + stats, + defaultColor +}: StatisticsPanelProps) { + const [showStats, setShowStats] = useState(true); + + const processedUserDefinedLayers = useMemo(() => { + // Group layers by 'layerName' + const groupLayersByKey = (layers: UserDefinedLayer[]) => { + return layers.reduce((acc, layer) => { + acc[layer.layerName] = acc[layer.layerName] || []; + acc[layer.layerName].push(layer); + return acc; + }, {} as Record); + }; + + // Convert grouped layers into desired format + const groupedLayers = groupLayersByKey(userDefinedLayers); + return Object.entries(groupedLayers).map(([key, layers]) => ({ + key, + list: layers, + color: layers[0]?.col || defaultColor // Use layer color or default + })); + }, [userDefinedLayers, defaultColor]); + + const handleToggleStats = () => { + setShowStats(prev => !prev); + }; + + const renderLayerList = (layerList: UserDefinedLayer[] | undefined) => + layerList?.map(item => ( +

+ {item.geo}: {item.size} +

+ )); + + const renderTagStats = (userDefinedLayerKey: string) => { + const relevantNames = userDefinedNames.filter(layer => layer.key === userDefinedLayerKey); + + return relevantNames.flatMap(locItem => + Array.from(locItem.tagList || []).flatMap(meta => { + const tagItem = entityTags.find(tag => tag.tag === meta); + const shouldDisplay = tagItem?.simulationDisplay && stats[userDefinedLayerKey]?.[meta]; + + if (!shouldDisplay) return null; + + const num = stats[userDefinedLayerKey][meta]; + const val = isNumeric(num) && typeof num === 'number' ? Math.round(num).toLocaleString('en-US') : num; + + return ( +

+ {meta}: {val} +

+ ); + }) + ); + }; + + const renderTabs = () => + processedUserDefinedLayers.map(userDefinedLayer => ( + + <> + {renderLayerList(userDefinedLayer.list)} + {renderTagStats(userDefinedLayer.key)} + + + )); + + return ( +
!showStats && setShowStats(true)}> + {userDefinedLayers.length > 0 && !showStats && 'Show Stats'} + {showStats && renderTabs()} + {showStats && ( + + )} +
+ ); +} + +export default StatisticsPanel; diff --git a/src/features/planSimulation/components/SimulationMapView/util.ts b/src/features/planSimulation/components/SimulationMapView/util.ts new file mode 100644 index 00000000..ffa99960 --- /dev/null +++ b/src/features/planSimulation/components/SimulationMapView/util.ts @@ -0,0 +1,20 @@ +// finds ids of all the levels of provided node list, except the ones with geoLevel = structure +export const getIdsByGeographicLevel = (nodes: any[] | undefined): string[] => { + if (!Array.isArray(nodes)) return []; + return nodes.reduce((ids, node) => { + if (node.properties?.geographicLevel !== "structure") { + ids.push(node.identifier); + } + return ids.concat(getIdsByGeographicLevel(node.children ?? [])); + }, []); + } + + export const findNodeById = (nodes: any[] | undefined, targetId: string): any | undefined => { + if (!Array.isArray(nodes)) return undefined; + for (const node of nodes) { + if (node.identifier === targetId) return node; + const found = findNodeById(node.children, targetId); + if (found) return found; + } + return undefined; + } \ No newline at end of file diff --git a/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.module.css b/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.module.css new file mode 100644 index 00000000..c1e339a6 --- /dev/null +++ b/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.module.css @@ -0,0 +1,128 @@ +.targetsSelectedList { + position: absolute; + z-index: 3; + right: 0; + width: 250px; + background: rgba(255, 255, 255, 0.863); + border: 1px solid #ccc; + max-height: 500px; + overflow: auto; + font-size: 0.9rem; +} + +.listWrapper { + list-style: none; + margin-bottom: 0; +} + +.listWrapper p { + margin: 0; +} + +.divider { + margin: 0.8rem 0; +} + +.listItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + transition: background-color 0.2s; +} + +.listItem:hover { + background-color: #f5f5f580; +} + +.listItemInfo { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + gap: 0.5rem; +} + +.selectedIcon { + filter: brightness(0) saturate(100%) invert(21%) sepia(91%) saturate(7190%) hue-rotate(358deg) brightness(109%) + contrast(115%); + width: 10px; + height: 10px; + margin-right: 0.2rem; +} + +.removeLocationButton { + border: none; + padding: 0 0.5rem; + display: flex; + justify-content: center; + align-items: center; + text-decoration: none; + border-radius: 7px; + background: transparent; +} + +.removeLocationButton img { + width: 17px; + height: 17px; + opacity: 0.7; + transition: all 0.2s; +} + +.removeLocationButton:hover img { + opacity: 1; +} + +.targetsSelectedList::-webkit-scrollbar { + width: 5px; + display: none; +} + +.targetsSelectedList:hover::-webkit-scrollbar { + display: block; +} + +.targetsSelectedList::-webkit-scrollbar-track { + background: #0000001a; + margin: 4px; +} + +.targetsSelectedList::-webkit-scrollbar-thumb { + background-color: #949494; + border-radius: 10px; +} + +.targetsSelectedList::-webkit-scrollbar-button { + display: none !important; +} + +.targetsSelectedList::-webkit-scrollbar-thumb:hover { + background-color: #949494a1; +} + +.buttonWrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.assignmentButton { + background-color: #059669; + width: 100%; + color: white; + border: none; + border-radius: 0.375rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s; + text-decoration: none; + text-align: center; +} + +.assignmentButton:hover { + background-color: #047857; + color: white; +} diff --git a/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.tsx b/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.tsx new file mode 100644 index 00000000..7e550e78 --- /dev/null +++ b/src/features/planSimulation/components/TargetsSelectedList/TargetsSelectedList.tsx @@ -0,0 +1,113 @@ +import React, { useMemo, useCallback } from 'react'; +import styles from './TargetsSelectedList.module.css'; +import { usePolygonContext } from '../../../../contexts/PolygonContext'; +import Accordion from '../../../location/components/accordion/Accordion'; +import task from '../../../../assets/svgs/task.svg'; +import Delete from '../../../../assets/svgs/trash-bin.svg'; +import { assignLocationsToPlan } from '../SimulationMapView/api/planAPI'; +import { findNodeById, getIdsByGeographicLevel } from '../SimulationMapView/util'; +import { getSimulationData } from '../SimulationMapView/api/datasetsAPI'; + +function TargetsSelectedList() { + const { state, dispatch } = usePolygonContext(); + const { multiselect, polygons } = state; + + // Helper function to find a parent by its identifier + const findParent = useCallback((data: any[], parentId: string): any => { + for (const polygon of data) { + if (polygon.identifier === parentId) { + return polygon; + } + if (polygon.children.length > 0) { + const found = findParent(polygon.children, parentId); + if (found) return found; + } + } + return undefined; + }, []); + + // Group selected polygons by their parent (memoized for optimization) + const groupedData = useMemo(() => { + const grouped: Record = {}; + + multiselect.forEach(polygon => { + const parent = findParent(polygons, polygon.properties.parentIdentifier); + const parentName = parent?.properties.name || 'Unknown Parent'; + + if (!grouped[parentName]) { + grouped[parentName] = []; + } + grouped[parentName].push(polygon); + }); + + return grouped; + }, [multiselect, findParent, polygons]); + + // Function to render nested children recursively (memoized) + const renderChildren = useCallback( + (polygon: any, key) => { + return ( +
  • +
    + selected Polygon +

    + {polygon.properties.name} ({polygon.properties.childrenNumber}) +

    +
    + +
  • + ); + }, + [dispatch] + ); + + // Handler for logging polygon identifiers + const handleLogIdentifiers = (polygons: any) => { + // same logic as for single select assignment, check SimulationMapView.tsx handleCampaignClick + const ancestry: any[] = []; + const results: any[] = []; + polygons.forEach((polygon: any) => { + ancestry.push(...JSON.parse(polygon.properties?.ancestry) || []); + const currentLocWithChildren = findNodeById(state.polygons, polygon.properties?.id); + results.push(...getIdsByGeographicLevel(currentLocWithChildren.children)); + }); + const assignedAreas = state.targetAreas?.flatMap(ta => [...ta.ancestry, ta.identifier]) || []; + + const allLocationsIdsToBeAssigned = new Set([...results, ...ancestry, ...assignedAreas, ...polygons.map((p: any) => p.properties?.id)]); + const identifiersToSendArray = Array.from(allLocationsIdsToBeAssigned); + assignLocationsToPlan(state.planid, identifiersToSendArray).then(async () => { + const simulationData = await getSimulationData(state.planid); + dispatch({ type: 'SET_TARGET_AREAS', payload: simulationData.targetAreas }); + dispatch({ type: 'CLEAR_SELECTION' }); + dispatch({ + type: 'SET_ASSIGNED', payload: { + ...state.assingedLocations, ...Object.fromEntries(polygons.map((p: any) => [p.properties?.id, true])) + } + }); + + }); + }; + + return ( +
    + {Object.entries(groupedData).map(([parentName, polygons], index) => ( + +
      {polygons.map((polygon, index) => renderChildren(polygon, index))}
    +
    +
    + +
    +
    + ))} +
    + ); +} + +export default React.memo(TargetsSelectedList); diff --git a/src/features/planSimulation/components/Teams/Teams.module.css b/src/features/planSimulation/components/Teams/Teams.module.css new file mode 100644 index 00000000..afe68b05 --- /dev/null +++ b/src/features/planSimulation/components/Teams/Teams.module.css @@ -0,0 +1,100 @@ +.container { + border-radius: 8px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.usersIcon { + width: 15px; + height: 15px; +} + +.title { + font-size: 14px; + font-weight: 600; + color: #4b5563; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.teamGroup { + margin-bottom: 1px; +} + +.teamHeader { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + user-select: none; + transition: background-color 0.2s; +} + +.teamName { + display: flex; + align-items: center; + gap: 8px; + color: #111827; + font-weight: 500; +} + +.chevron { + color: #6b7280; + transition: transform 0.2s; +} + +.membersList { + overflow: hidden; + transition: height 0.2s ease-out; +} + +.member { + display: flex; + padding: 0.3rem 0rem; + align-items: center; + font-size: 12px; + justify-content: space-between; +} + +.memberInfo { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.memberName { + color: #374151; +} + +.memberUsername { + color: #6b7280; + font-size: 12px; + font-style: italic; + margin-right: 1px; +} + +.emptyTeam { + color: #6b7280; + font-size: 12px; + font-style: italic; +} + +.statusIndicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.statusIndicator.active { + background-color: #10b981; +} + +.statusIndicator.inactive { + background-color: #e5e7eb; +} diff --git a/src/features/planSimulation/components/Teams/Teams.tsx b/src/features/planSimulation/components/Teams/Teams.tsx new file mode 100644 index 00000000..fef346f1 --- /dev/null +++ b/src/features/planSimulation/components/Teams/Teams.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; +import style from './Teams.module.css'; +import Accordion from '../../../location/components/accordion/Accordion'; +import Users from '../../../../assets/svgs/users.svg'; + +interface TeamsProps { + teamsList: Team[]; + fetchTeamsData: () => void; +} + +interface Member { + identifier: string; + firstName: string; + lastName: string; + username: string; +} + +interface Team { + identifier: string; + name: string; + members: Member[]; + active?: boolean; +} + +const Teams = ({ teamsList, fetchTeamsData }: TeamsProps) => { + useEffect(() => { + fetchTeamsData(); + }, []); + + return ( + <> +
    + {teamsList.map(team => ( + +
    +
    + users + {team.name} +
    + {/* */} +
    + } + > +
    + {team.members.length > 0 ? ( + team.members.map(member => ( +
    +
    + + {member.firstName} {member.lastName} + + {member.username} +
    +
    + )) + ) : ( +
    No members
    + )} +
    +
    + ))} +
    + + ); +}; + +export default Teams; diff --git a/src/features/planSimulation/components/Teams/api/teamAPI.ts b/src/features/planSimulation/components/Teams/api/teamAPI.ts new file mode 100644 index 00000000..36217079 --- /dev/null +++ b/src/features/planSimulation/components/Teams/api/teamAPI.ts @@ -0,0 +1,36 @@ +import api from '../../../../../api/axios'; +import { toast } from 'react-toastify'; + +import { OrganizationModel, Groups } from '../../../../../features/organization/providers/types'; +import { PageableModel } from '../../../../../api/providers'; +import { ORGANIZATION } from '../../../../../constants'; + +export const getOrganizationList = async ( + size: number, + page: number, + search?: string, + sortField?: string, + direction?: boolean +): Promise> => { + const data = await api + .get>( + ORGANIZATION + + `?search=${search !== undefined ? search : ''}&size=${size}&page=${page}&_summary=FALSE&root=true&sort=${ + sortField !== undefined ? sortField : '' + },${direction ? 'asc' : 'desc'}` + ) + .then(response => response.data); + return data; +}; + +export const getOrganizationListSummary = async (): Promise> => { + const data = await api + .get>(ORGANIZATION + '?_summary=TRUE&root=false&size=500&page=0') + .then(response => response.data); + return data; +}; + +export const getOrganizatonsWithMembers = async (): Promise => { + const data = await api.get('/organization/members').then(response => response.data); + return data; +}; diff --git a/src/features/planSimulation/components/User/api/userAPI.ts b/src/features/planSimulation/components/User/api/userAPI.ts new file mode 100644 index 00000000..8e5fdf9e --- /dev/null +++ b/src/features/planSimulation/components/User/api/userAPI.ts @@ -0,0 +1,157 @@ +import api from '../../../../../api/axios'; +import { toast } from 'react-toastify'; +import { OrganizationModel } from '../../../../../features/organization/providers/types'; +import { ORGANIZATION, USER } from '../../../../../constants'; +import axios, { AxiosResponse } from 'axios'; +export interface CreateUserRequest { + email: string; + firstName: string; + lastName: string; + organizations: string[]; + password: string; + securityGroups: string[]; + username: string; + +} +export interface MemberModel { + identifier: string; + firstName: string; + lastName: string; + email: string; + role: string; + username: string; +} + +export interface UserModel { + identifier: string; + sid: string; + username: string; + firstName: string; + lastName: string; + email: string; + password?: string; + tempPassword?: boolean; + organizations: OrganizationModel[]; + securityGroups: string[]; + selectedAll?: boolean; +} +export interface UserListResponse { + content: UserModel[]; + totalElements: number; + totalPages: number; + pageable: { + sort: { + sorted: boolean; + unsorted: boolean; + empty: boolean; + }; + pageNumber: number; + pageSize: number; + offset: number; + paged: boolean; + unpaged: boolean; + }; + numberOfElements: number; + number: number; + size: number; + empty: boolean; + first: boolean; + last: boolean; + sort: { + sorted: boolean; + unsorted: boolean; + empty: boolean; + }; +} + +export interface CreateUserResponse { + userId: string; + email: string; + firstName: string; + lastName: string; + organizations: string[]; + securityGroups: string[]; + username: string; + success?: boolean; +} + +export interface AddUserToOrganizationRequest { + username: string; +} + +export interface AddUserToOrganizationResponse { + success: boolean; + message: string; +} + +export const getUserList = async ( + search?: string, + filters?: { firstName?: string; lastName?: string; email?: string }, + sortField?: string, + direction?: boolean +): Promise => { + const params = new URLSearchParams(); + + if (search) params.append('search', search); + + if (filters) { + if (filters.firstName) params.append('firstName', filters.firstName); + if (filters.lastName) params.append('lastName', filters.lastName); + if (filters.email) params.append('email', filters.email); + } + + if (sortField) { + params.append('sort', `${sortField},${direction ? 'asc' : 'desc'}`); + } + + const response = await api.get(`${USER}?${params.toString()}`); + + return response.data.content; +}; + +export const createUser = async (data: CreateUserRequest): Promise => { + try { + const response: AxiosResponse = await api.post('/user', data); + return response; + } catch (error) { + console.error('Error creating user:', error); + return null; + } +}; + +// create + +export const createOrganization = async (organization: any): Promise => { + const data = await api.post(ORGANIZATION, organization).then(response => response.data); + return data; +}; + +export const getOrganizationMembers = async (organizationId: string): Promise => { + try { + const response = await api.get(`/organization/${organizationId}/members`); + return response.data; + } catch (error) { + console.error('Error fetching organization members:', error); + throw error; + } +}; + +export const addUserToOrganization = async (organizationId: string, username: string) => { + try { + const response = await api.post(`/organization/${organizationId}/members`, username); + return response.data; + } catch (error) { + console.error('Error adding user to organization:', error); + throw error; + } +}; + +export const deleteUserFromOrganization = async (organizationId: string, username: string) => { + try { + const response = await api.delete(`/organization/${organizationId}/members/${username}`); + return response.data; + } catch (error) { + console.error('Error deleting user from organization:', error); + throw error; + } +}; diff --git a/src/features/planSimulation/components/Users.module.css b/src/features/planSimulation/components/Users.module.css new file mode 100644 index 00000000..a2730174 --- /dev/null +++ b/src/features/planSimulation/components/Users.module.css @@ -0,0 +1,511 @@ +.modalContainer { + display: flex; + flex-direction: row; + height: 500px; +} + +.sidebar { + width: 200px; + background-color: #f8f9fa; + padding: 15px; + border-right: 1px solid #dee2e6; +} +.fullModal { + width: 90vw; + max-width: 1200px; + height: auto; + min-height: 400px; + max-height: 90vh; + margin: auto; + background-color: #ffffff; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + overflow-y: auto; + display: flex; + flex-direction: column; + padding-left: 15px; + padding-right: 18px !important; + padding:2px; +} +.fullModal h1 { + font-size: 24px; + font-weight: 600; + color: black; + padding: 10px; + border-bottom: 1px solid #dee2e6; + margin: 0; +} + +.sidebarButton { + display: block; + width: 100%; + background: none; + border: none; + text-align: left; + padding: 10px; + font-weight: 500; + cursor: pointer; + color: #333; +} + +.sidebarButton.active { + background-color: #e9ecef; +} + +.content { + flex-grow: 1; + padding: 20px; +} + +.formContainer { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +.avatar { + width: 100px; + height: 100px; + border-radius: 50%; + background-color: blue; + margin-bottom: 20px; +} + +.formGroup { + width: 100%; + margin-bottom: 40px; +} + +.formButtons { + display: flex; + justify-content: space-between; + margin-top: 20px; + gap: 10px; +} +.fullForm { + width: 100%; + background-color: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border-radius: 8px; + padding: 20px; +} + +.buttonPrimary { + background-color: #254c8b; + color: white; + border: none; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; +} + +.buttonSecondary { + background-color: #6c757d; + color: white; + border: none; + padding: 8px 12px; + border-radius: 5px; + cursor: pointer; +} + +.buttonContainer { + display: flex; + justify-content: center; + margin-top: 20px; + gap: 20px; +} + +.sidebarNav { + display: flex; + flex-direction: column; + gap: 10px; + background-color: white; + border-right: #d4d1d1; + border-right-width: 1px; + border-right-style: solid; + height: auto; + min-height: 420px; + overflow-y: auto; +} + + +.modal-dialog { + min-height: 400px; +} + +.modal-content { + height: 100%; +} + +.modalFull { + width: 100%; + min-height: 400px; +} + +.title { + font-size: 20px; + font-weight: 500; + margin-bottom: 20px; +} + +.settings { + display: flex; +} +.navsettingItemMenu { + display: flex; + align-items: center; + gap: 8px; + color: black !important; + font-size: 17px; + white-space: nowrap; +} +.settingIcon { + position: relative; + width: 20px; + height: 16px; + margin-right: 10px; + font-size: 16px; +} + +.manageIcon { + position: relative; + width: 20px; + height: 16px; + margin-right: 10px; + font-size: 16px; +} + +.manageItemMenu { + display: flex; + align-items: center; + gap: 8px; + color: black !important; + font-size: 17px; + white-space: nowrap; + transition: background-color 0.3s, color 0.3s; + width: 100%; + padding: 12px 20px; + border-radius: 8px; +} + +.manageTeams { + display: flex; + width: 100%; + color: black; +} + +.manageItemMenu:hover { + background-color: #085ED6 !important; + color: white !important; +} + +/* .manageItemMenu.active { + background-color: #007bff; + color: white !important; +} */ + +.viewIcon { + position: relative; + width: 20px; + height: 16px; + margin-right: 10px; + font-size: 16px; +} + +.viewItemMenu { + display: flex; + align-items: center; + gap: 8px; + color: black !important; + font-size: 17px; + white-space: nowrap; + transition: background-color 0.3s, color 0.3s; + width: 100%; + padding: 12px 20px; + border-radius: 8px; +} + +.viewUsers { + display: flex; + width: 100%; + color: black; +} + + +.viewItemMenu:hover { + background-color: #085ED6 !important; + color: white !important; +} + + + + +.userIcon { + position: relative; + width: 20px; + height: 16px; + margin-right: 10px; + font-size: 17px; +} + +.userItemMenu { + display: flex; + align-items: center; + gap: 8px; + color: black !important; + font-size: 17px; + white-space: nowrap; + transition: background-color 0.3s, color 0.3s; + width: 100%; + padding: 12px 20px; + border-radius: 8px; +} + +.userPlus { + display: flex; + width: 100%; + color: black; +} + +.userItemMenu:hover { + background-color: #085ED6 !important; + color: white !important; +} + +.userIcon:hover { + color: white !important; +} + + + + + +.viewItemMenu.active, +.userItemMenu.active, +.manageItemMenu.active { + background-color: #085ED6 !important; + color: white !important; +} + +.viewItemMenu, +.userItemMenu, +.manageItemMenu { + color: black !important; +} + +.nav-item .viewItemMenu.active, +.nav-item .userItemMenu.active, +.nav-item .manageItemMenu.active { + background-color: #085ED6 !important; + color: white !important; +} + +.nav-item .viewItemMenu:hover .viewIcon, +.nav-item .userItemMenu:hover .userIcon, +.nav-item .manageItemMenu:hover .manageIcon { + color: white !important; +} + + + +/* .searchContainer { + margin-top: 10px; + margin-bottom: 10px; +} */ + +.bodyClass { + height: 100%; + background-color: #f9fafb; + padding-left: 10px; + padding-top: 0 !important; + padding-bottom: 0; +} + +.contentArea { + padding: 20px; +} + +.dropDown { + margin-bottom: 16px; +} + +.columnField { + margin-top: 5px; +} + +.userColumn { + border: 1px solid #dee2e6; + background-color: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + min-height: 350px; + padding: 10px; +} + +.arrowClass { + text-align: center; + margin-top: 80px; +} + +.openModalButton { + background-color: white !important; + color: black !important; + border: 1px solid black; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + width: 200px; + transition: background 0.3s ease !important; +} + +.userColumn { + border: 1px solid #dee2e6; + background-color: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + min-height: 350px; + padding: 10px; + display: flex; + flex-direction: column; + align-items: center; +} + +.columnHeader { + text-align: center; + font-size: 18px; + font-weight: bold; + width: 100%; + margin-bottom: 10px; + padding: 5px 0; + border-bottom: 2px solid #dee2e6; +} + +.userList { + list-style: none; + padding: 0; + width: 100%; +} + +.userItem { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + cursor: pointer; + transition: background 0.3s; +} + +.userItem:hover { + background-color: #f8f9fa; +} + +.selected { + background-color: #e9ecef; + font-weight: bold; +} + + + +.arrowClass { + display: flex !important; + flex-direction: column !important; + + align-items: center !important; + justify-content: center !important; + height: 100%; + margin-top: 100px !important; +} + +.viewUserContainer { + margin-top: 20px; + padding-left: 10px; +} + +.usersSection, +.teamsSection { + border: 1px solid #dee2e6; + background-color: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + min-height: 350px; + padding: 10px; + padding-left: 20px; +} + + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 10px; + border-bottom: 2px solid #ddd; + text-align: left; +} + +.controls { + display: flex; + align-items: center; + gap: 10px; +} + + +.searchInput { + height: 35px; + padding: 0 10px; + font-size: 16px; + border: 1px solid #ddd; + border-radius: 4px; + width: 200px; + box-sizing: border-box; + outline: none; + transition: border-color 0.3s ease; +} + + +.searchInput:focus { + border-color: #085ED6; +} + + +.controlIcon { + cursor: pointer; + color: #555; + font-size: 18px; + transition: color 0.3s ease; +} + + +.controlIcon:hover { + color: #085ED6; +} + + +.usersList, +.teamsList { + list-style: none; + padding: 0; + margin: 0; +} + +.userEntry, +.teamEntry { + display: flex; + align-items: center; + padding: 10px; + cursor: pointer; + transition: background 0.3s; +} + +.userEntry:hover, +.teamEntry:hover { + background-color: #f8f9fa; +} + +.activeEntry { + background-color: #e9ecef; + font-weight: bold; +} + +.userAvatar, +.teamAvatar { + margin-right: 10px; + color: #007bff; +} diff --git a/src/features/planSimulation/components/UsersModal.tsx b/src/features/planSimulation/components/UsersModal.tsx new file mode 100644 index 00000000..c7ee2a13 --- /dev/null +++ b/src/features/planSimulation/components/UsersModal.tsx @@ -0,0 +1,817 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Button, Col, Container, Form, Modal, Row, Nav, Dropdown } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Select, { MultiValue, SingleValue } from 'react-select'; +import { DebounceInput } from 'react-debounce-input'; + +import { + CreateUserRequest, + CreateUserResponse, + createUser, + UserModel, + getOrganizationMembers, + getUserList, + addUserToOrganization, + MemberModel, + createOrganization, + deleteUserFromOrganization +} from '../../planSimulation/components/User/api/userAPI'; +import { getOrganizationList } from './Teams/api/teamAPI'; +import { getOrganizationListSummary, getSecurityGroups, getOrganizationById } from '../../organization/api'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'react-toastify'; +import { REGEX_EMAIL_VALIDATION, REGEX_NAME_VALIDATION, REGEX_USERNAME_VALIDATION } from '../../../constants/constants'; +import { + faUserPlus, + faUsers, + faUser, + faSort, + faFilter, + faCogs, + faUsersCog, + faArrowRight, + faArrowLeft, + faArrowUp, + faArrowDown +} from '@fortawesome/free-solid-svg-icons'; +import style from './Users.module.css'; +import CreateUser from '../../user/components/UsersPage/create/CreateUser'; +import { OrganizationModel } from '../../organization/providers/types'; +import { FieldValidationError } from '../../../api/providers'; +import Accordion from '../../location/components/accordion/Accordion'; +import { string } from 'mathjs'; +import { AxiosResponse } from 'axios'; +interface RegisterValues { + username: string; + firstname: string; + lastname: string; + email: string; + password: string; + securityGroups: string[]; + organizations: string[]; + bulk: File[]; +} +interface Option { + value: string; + label: string; +} + +interface Options { + value: string; + label: string; +} +interface UserModalProps { + className?: string; + fetchTeamsData: () => void; +} + +interface TeamsRegisterValues { + name: string; + type: string; + partOf: string; + active: boolean; +} + +export default function UserModal({ fetchTeamsData }: UserModalProps) { + const [selectedSecurityGroups, setSelectedSecurityGroups] = useState(); + const [selectedTeamSecurityGroups, setSelectedTeamSecurityGroups] = useState>(); + const [teamOrganization, setteamOrganization] = useState([]); + const [selectedOrganizations, setSelectedOrganizations] = useState(); + const [organizations, setOrganizations] = useState([]); + const [groups, setGroups] = useState(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [sortField, setSortField] = useState('firstName'); + const [sortDirection, setSortDirection] = useState(true); + + const [teamSortField, setTeamSortField] = useState('name'); + const [teamSortDirection, setTeamSortDirection] = useState(true); + + const [teams, setTeams] = useState([]); + const [direction, setDirection] = useState(true); + const [teamSearchTerm, setTeamSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [showModal, setShowModal] = useState(false); + const [filters, setFilters] = useState({ firstName: '', lastName: '', email: '' }); + const [activeTab, setActiveTab] = useState('viewUsers'); + // const [show, setShow] = useState(false); + const handleClose = () => setShowModal(false); + const handleShow = () => setShowModal(true); + + const [allUsers, setAllUsers] = useState([]); + + const [sortedUsers, setSortedUsers] = useState([]); + + const [viewType, setViewType] = useState('Available Members'); + + const [teamsList, setTeamsList] = useState<{ name: string; id: string }[]>([]); + + const [selectedTeam, setSelectedTeam] = useState(''); + const [selectedTeamId, setSelectedTeamId] = useState(''); + const [selectedTeamName, setSelectedTeamName] = useState(''); + + // Dynamic filtering for Available/All Members dropdown + + const [assignedUsers, setAssignedUsers] = useState([]); + + const [selectedAvailable, setSelectedAvailable] = useState(null); + const [selectedAssigned, setSelectedAssigned] = useState(null); + + const { + reset, + register, + handleSubmit, + setError, + formState: { errors } + } = useForm(); + + const getData = useCallback(() => { + getSecurityGroups().then(res => { + setGroups( + res.map(el => { + return { + value: el.name, + label: el.name + }; + }) + ); + }); + getOrganizationListSummary().then(res => { + setOrganizations( + res.content.map(el => { + return { + value: el.identifier, + label: el.name + }; + }) + ); + }); + }, []); + + useEffect(() => { + getData(); + }, [getData]); + const selectHandler = (selectedOption: MultiValue<{ value: string; label: string }>) => { + const values = selectedOption.map(selected => { + return selected; + }); + setSelectedSecurityGroups(values); + }; + const organizationSelectHandler = (selectedOption: MultiValue<{ value: string; label: string }>) => { + const values = selectedOption.map(selected => { + return selected; + }); + setSelectedOrganizations(values); + }; + const submitHandler = (formValues: RegisterValues) => { + let newUser: CreateUserRequest = { + username: formValues.username, + email: formValues.email === '' ? '' : formValues.email, + firstName: formValues.firstname, + lastName: formValues.lastname, + organizations: selectedOrganizations?.map(el => el.value) ?? [], + password: formValues.password, + securityGroups: selectedSecurityGroups?.map(el => el.value) ?? [] + }; + createUser(newUser).then((res: AxiosResponse | null) => { + console.log(res, 'the response of the create user'); + if (res) { + toast.success('User created successfully!'); + fetchTeamsData(); + reset(); + } else { + toast.error('Failed to create user'); + } + }); + }; + + /// users list logic + + const fetchUsers = useCallback(async () => { + try { + setLoading(true); + const allUsers = await getUserList(); + console.log('allUsers', allUsers); + const uniqueUsers = Array.from(new Map(allUsers.map(user => [user.identifier, user])).values()); + setSortedUsers(uniqueUsers); + setUsers(sortUsers(uniqueUsers, sortField, direction)); + } catch (error) { + } finally { + setLoading(false); + } + }, [sortField, direction]); + + useEffect(() => { + fetchUsers(); + }, []); + + const sortUsers = (userList: UserModel[], field: string, direction: boolean): UserModel[] => { + return [...userList].sort((a, b) => { + if (field === 'firstName') { + return direction ? a.firstName.localeCompare(b.firstName) : b.firstName.localeCompare(a.firstName); + } + return 0; + }); + }; + + const handleSortClick = (field: string) => { + const isSameField = sortField === field; + const newSortDirection = isSameField ? !direction : true; + + setSortField(field); + setDirection(newSortDirection); + + const sorted = sortUsers(sortedUsers, field, newSortDirection); + setUsers(sorted); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + filterUsers(e.target.value); + }; + + const filterUsers = (term: string) => { + if (!term.trim()) { + setUsers(sortedUsers); + + return; + } + + const filtered = sortedUsers.filter(user => + `${user.firstName} ${user.lastName}`.toLowerCase().includes(term.toLowerCase()) + ); + + setUsers(filtered); + }; + + /// users list logic end here + + /// teams logic + const fetchTeams = async () => { + setLoading(true); + try { + const response = await getOrganizationListSummary(); + + const teamNames = response.content.map(org => ({ + name: org.name, + id: org.identifier + })); + + setTeams(teamNames.map(team => team.name)); + setTeamsList(teamNames); + } catch (error) { + console.error('Error fetching teams:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTeams(); + fetchTeams(); + }, []); + const filteredTeams = teams.filter(team => team.toLowerCase().includes(teamSearchTerm.toLowerCase())); + + const sortedTeams = [...filteredTeams].sort((a, b) => { + if (teamSortField === 'name') { + return teamSortDirection ? a.localeCompare(b) : b.localeCompare(a); + } + return 0; + }); + const handleSortToggle = () => { + setTeamSortDirection(prev => !prev); + }; + + // teams end here + + const { + register: registerTeams, + handleSubmit: handleSubmitTeams, + control, + reset: resetTeams, + setError: setErrorTeams, + formState: { errors: errorsTeams } + } = useForm(); + + useEffect(() => { + getOrganizationListSummary().then(res => { + setteamOrganization(res.content); + }); + }, []); + + const submitTeamHandler = (formValues: TeamsRegisterValues) => { + const organizationData = { + ...formValues, + type: 'TEAM', + partOf: '19c9a023-fff3-4aa8-a355-999effadc4cc', + active: true + }; + toast.promise(createOrganization(organizationData), { + pending: 'Loading...', + success: { + render({ data }: { data: OrganizationModel }) { + resetTeams(); + fetchTeamsData(); + return `Organization with id: ${data.identifier} created successfully.`; + } + } + }); + }; + + const handleTeamSelection = (teamId: string) => { + if (teamId === '') { + setSelectedTeam(''); + setSelectedTeamName(''); + setAssignedUsers([]); + return; + } + + const selectedTeamObj = teamsList.find(team => team.id === teamId); + + if (selectedTeamObj) { + setSelectedTeam(teamId); + setSelectedTeamName(selectedTeamObj.name); + setAssignedUsers([]); + + fetchMembers(teamId); + } + }; + const fetchMembers = async (organizationId: string) => { + try { + const members = await getOrganizationMembers(organizationId); + setAssignedUsers(members); + } catch (error) { + console.error('Error fetching members:', error); + } + }; + + // Move member to Team + + const moveToTeam = async (userId: string) => { + console.error('User ID or Team ID is missing'); + + const user = users.find(u => u.identifier === userId); + + if (!selectedTeam) { + console.error('Error: selectedTeamId is missing or undefined'); + return; + } + + if (user) { + const member: MemberModel = { + identifier: user.identifier, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + username: user.username, + role: 'default_role' + }; + + try { + const response = await addUserToOrganization(selectedTeam, user.username); + console.log(response, 'the response of the move to team'); + + if (Array.isArray(response) && response.length > 0) { + setAssignedUsers(prevAssigned => [...prevAssigned, member]); + + setUsers(prevUsers => prevUsers.filter(u => u.identifier !== userId)); + fetchUsers(); + fetchTeams(); + fetchMembers(selectedTeam); + toast.success('User moved to team successfully!'); + fetchTeamsData(); + + setSelectedAvailable(null); + } else { + toast.error('Failed to add user to team'); + console.error('Failed to add user to team'); + } + } catch (error) { + console.error('Error during API request:', error); + } + } + }; + + /// delete user from the team assigned + + const handleDeleteUser = async () => { + const userToDelete = assignedUsers.find(u => u.identifier === selectedAssigned); + + if (userToDelete) { + const username = userToDelete.username; + const organizationId = selectedTeam; + + try { + const response = await deleteUserFromOrganization(organizationId, username); + console.log(response, 'the response of the delete user'); + + if (Array.isArray(response) && !response.some(user => user.identifier === selectedAssigned)) { + setAssignedUsers(prevAssigned => prevAssigned.filter(user => user.identifier !== selectedAssigned)); + + // setUsers(prevUsers => prevUsers.filter(user => user.identifier !== selectedAssigned)); + fetchUsers(); + fetchTeams(); + fetchMembers(organizationId); + toast.success('User deleted from team successfully!'); + fetchTeamsData(); + setSelectedAssigned(null); + } else { + console.error('Failed to delete user'); + } + } catch (error) { + toast.error('Failed to delete user'); + console.error('Error during delete request:', error); + } + } + }; + const rightArrowDisabled = !selectedTeam; + return ( +
    +
    +

    Manage Users & Teams

    +
    +
    + + + + + + {activeTab === 'createUser' && ( + + + + Username + + {errors.username && {errors.username.message}} + + + + + + First Name + + {errors.firstname && ( + {errors.firstname.message} + )} + + + + + Last Name + + {errors.lastname && {errors.lastname.message}} + + + + + + Password + + {errors.password && {errors.password.message}} + + + + Email + + + + + Security Groups + + + +
    + + +
    + +
    + )} + {activeTab === 'manageTeams' && ( + + + + setViewType(e.target.value)}> + + + + + + handleTeamSelection(e.target.value)}> + + {teamsList.map((team, index) => ( + + ))} + + + + + + + {' '} +
    + + Teams name + + {errorsTeams.name && ( + {errorsTeams.name.message} + )} + + +
    + +
    + +
    +
    + + +
    {viewType}
    +
      + {viewType === 'Available Members' + ? users + .filter(user => !user.organizations.length) + .map(user => ( +
    • setSelectedAvailable(user.identifier)} + > + {user.firstName}{' '} + {user.lastName} +
    • + )) + : users.map(user => ( +
    • setSelectedAvailable(user.identifier)} + > + {user.firstName}{' '} + {user.lastName} +
    • + ))} +
    + + + + +
    + + + + +
    + {selectedTeamName ? `${selectedTeamName}'s Members` : 'Members'} +
    + + {assignedUsers.length > 0 ? ( +
      + {assignedUsers.map(user => ( +
    • setSelectedAssigned(user.identifier)} + > + + {user.firstName} {user.lastName} +
    • + ))} +
    + ) : ( +

    No members available.

    + )} + +
    +
    + )} + {activeTab === 'viewUsers' && ( + + +
    +
    Users
    +
    + + handleSortClick('firstName')} + /> + {/* */} +
    +
    +
    + {loading ? ( +

    Loading...

    + ) : ( +
    +
      + {users.map(user => ( +
    • + + {`${user.firstName} ${user.lastName}`} +
    • + ))} +
    +
    + )} +
    + + +
    +
    Teams
    +
    +
    + setTeamSearchTerm(e.target.value)} + className={style.searchInput} + /> +
    + + {/* */} +
    +
    + + {loading ? ( +

    Loading...

    + ) : ( +
      + {filteredTeams.length > 0 ? ( + sortedTeams.map((team, index) => ( +
    • + {team} +
    • + )) + ) : ( +
    • No teams found
    • + )} +
    + )} + +
    + )} + +
    +
    +
    + ); +} diff --git a/src/features/planSimulation/reportsAPI/reportsApi.ts b/src/features/planSimulation/reportsAPI/reportsApi.ts new file mode 100644 index 00000000..23a8717f --- /dev/null +++ b/src/features/planSimulation/reportsAPI/reportsApi.ts @@ -0,0 +1,6 @@ +import api from '../../../api/axios'; + +export const getReportForLocation = async (planid: string, locationId: string): Promise => { + const data = await api.get(`/task-details?planId=${planid}&locationId=${locationId}`).then(response => response.data); + return data; +}; diff --git a/src/features/reporting/components/PerformanceDashboard/PerformanceDetailsModal/PerformanceDetailsModal.tsx b/src/features/reporting/components/PerformanceDashboard/PerformanceDetailsModal/PerformanceDetailsModal.tsx index 90552246..fed5d71a 100644 --- a/src/features/reporting/components/PerformanceDashboard/PerformanceDetailsModal/PerformanceDetailsModal.tsx +++ b/src/features/reporting/components/PerformanceDashboard/PerformanceDetailsModal/PerformanceDetailsModal.tsx @@ -16,7 +16,7 @@ const PerformanceDetailsModal = ({ closeHandler, data, title, darkMode }: Props) centered onHide={closeHandler} backdrop="static" - dialogClassName='modal-90w' + dialogClassName="modal-90w" contentClassName={darkMode ? 'bg-dark' : 'bg-white'} > diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 72b21b0e..a4cd4ca9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -21,7 +21,8 @@ "TagManagement": "Tag Management", "MetaDataImport": "Metadata Import", "Resource Planning": "Resource Planning", - "dataProcessingProgress": "Data Processing Progress" + "dataProcessingProgress": "Data Processing Progress", + "CampaignManage": "Campaign Management" }, "publicPage": { "supportedBy": "supported by", diff --git a/src/index.tsx b/src/index.tsx index 52948ded..c6bad6f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,7 @@ import { ReactKeycloakProvider } from '@react-keycloak/web'; import keycloak from './keycloak'; import 'mapbox-gl/dist/mapbox-gl.css'; import { KeycloakInitOptions } from 'keycloak-js'; +import { PolygonProvider } from './contexts/PolygonContext'; const initOptions: KeycloakInitOptions = { pkceMethod: 'S256', @@ -21,7 +22,9 @@ ReactDOM.render( - + + + diff --git a/src/pages/Campaign/Campaign.tsx b/src/pages/Campaign/Campaign.tsx new file mode 100644 index 00000000..c03a6203 --- /dev/null +++ b/src/pages/Campaign/Campaign.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next'; +import { Route, Routes } from 'react-router-dom'; +import AuthGuard from '../../components/AuthGuard'; +import { ErrorPage } from '../../components/pages'; +import PageWrapper from '../../components/PageWrapper'; +import { REVEAL_SIMULATION } from '../../constants'; +import Simulation from '../../features/planSimulation/components/Simulation'; +import Campaign_Management from '../../features/planSimulation/components/CampaignManagement'; + +const Campaign = () => { + const { t } = useTranslation(); + + return ( + + + + + + } + /> + + } /> + + + ); +}; + +export default Campaign; diff --git a/src/pages/Campaign/index.ts b/src/pages/Campaign/index.ts new file mode 100644 index 00000000..47f73474 --- /dev/null +++ b/src/pages/Campaign/index.ts @@ -0,0 +1,6 @@ + +import Campaign from './Campaign'; + + + +export default Campaign; \ No newline at end of file diff --git a/src/pages/PlanSimulationPage/PlanSimulation.tsx b/src/pages/PlanSimulationPage/PlanSimulation.tsx index c11fb155..c6604c8a 100644 --- a/src/pages/PlanSimulationPage/PlanSimulation.tsx +++ b/src/pages/PlanSimulationPage/PlanSimulation.tsx @@ -10,7 +10,7 @@ const PlanSimulation = () => { const { t } = useTranslation(); return ( - + { - // console.log(e); - // }); - let initParentData: PlanningParentLocationResponse = { features: [], type: 'FeatureCollection', @@ -167,8 +166,16 @@ export const initMap = ( style: style, center: center, zoom: zoom, + logoPosition: 'top-right', + attributionControl: false, doubleClickZoom: false }); + mapboxInstance.addControl( + new AttributionControl({ + compact: true + }), + 'bottom-left' + ); mapboxInstance.addControl( new GeolocateControl({ positionOptions: { @@ -188,6 +195,7 @@ export const initMap = ( //initialize an empty top layer for all labels //this layer is used to prevent labels getting behind fill and border layers on loading of locations mapboxInstance.on('load', () => { + mapboxInstance.resize(); mapboxInstance.addSource('label-source', { type: 'geojson', data: { @@ -707,7 +715,7 @@ export const loadChildren = (map: Map, id: string, planId: string, opacity: numb }); }; -export const createLocationLabel = (map: Map, data: any, center: Feature) => { +export const createLocationLabel = (map: Map, data: any, center: Feature, color?: string) => { if (map.getSource(data.identifier + 'Label') === undefined) { map.addSource(data.identifier + 'Label', { type: 'geojson', @@ -717,7 +725,8 @@ export const createLocationLabel = (map: Map, data: any, center: Feature