diff --git a/i18n/en.pot b/i18n/en.pot index c278404f..f5bd524b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,22 +5,28 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-06-07T18:24:28.361Z\n" -"PO-Revision-Date: 2024-06-07T18:24:28.361Z\n" +"POT-Creation-Date: 2024-06-17T13:56:47.078Z\n" +"PO-Revision-Date: 2024-06-17T13:56:47.078Z\n" msgid "Add new option" msgstr "" +msgid "Create Event" +msgstr "" + msgid "N/A" msgstr "" -msgid "Incident Management Team Builder" +msgid "Close" msgstr "" -msgid "Cholera in NW Province, June 2023" +msgid "Search" msgstr "" -msgid "Create Event" +msgid "Incident Management Team Builder" +msgstr "" + +msgid "Cholera in NW Province, June 2023" msgstr "" msgid "Incident Action Plan" diff --git a/i18n/es.po b/i18n/es.po index 02dd6634..e9fe5620 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-06-07T18:24:28.361Z\n" +"POT-Creation-Date: 2024-06-17T13:56:47.078Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -11,16 +11,22 @@ msgstr "" msgid "Add new option" msgstr "" +msgid "Create Event" +msgstr "" + msgid "N/A" msgstr "" -msgid "Incident Management Team Builder" +msgid "Close" msgstr "" -msgid "Cholera in NW Province, June 2023" +msgid "Search" msgstr "" -msgid "Create Event" +msgid "Incident Management Team Builder" +msgstr "" + +msgid "Cholera in NW Province, June 2023" msgstr "" msgid "Incident Action Plan" diff --git a/package.json b/package.json index 2ce199e8..25b6254e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "d2": "31.10.2", "d2-manifest": "1.0.0", "font-awesome": "4.7.0", + "lodash": "^4.17.21", "purify-ts": "1.2.0", "purify-ts-extra-codec": "0.6.0", "react": "^18.2.0", @@ -49,6 +50,7 @@ "@testing-library/react": "^14.0.0", "@types/classnames": "2.3.1", "@types/isomorphic-fetch": "^0.0.36", + "@types/lodash": "^4.17.5", "@types/material-ui": "^0.21.12", "@types/node": "18", "@types/node-localstorage": "^1.3.0", diff --git a/src/types/d2-ui.d.ts b/src/types/d2-ui.d.ts index 64802194..539ca0bc 100644 --- a/src/types/d2-ui.d.ts +++ b/src/types/d2-ui.d.ts @@ -10,4 +10,7 @@ declare module "@dhis2/ui" { export function IconCalendar24(props: { color?: string }): React.ReactElement; export function IconChevronDown24(props: { color?: string }): React.ReactElement; export function IconCross24(props: { color?: string }): React.ReactElement; + export function IconCross16(props: { color?: string }): React.ReactElement; + export function IconSearch24(props: { color?: string }): React.ReactElement; + export function IconInfo24(props: { color?: string }): React.ReactElement; } diff --git a/src/webapp/components/add-new-option/AddNewOption.tsx b/src/webapp/components/add-new-option/AddNewOption.tsx index d101c172..7abfa140 100644 --- a/src/webapp/components/add-new-option/AddNewOption.tsx +++ b/src/webapp/components/add-new-option/AddNewOption.tsx @@ -13,8 +13,8 @@ interface AddNewOptionProps { export const AddNewOption: React.FC = React.memo( ({ id, label = "", onAddNewOption }) => { return ( - - + + ); @@ -24,10 +24,14 @@ export const AddNewOption: React.FC = React.memo( const Container = styled.div` display: flex; align-items: center; + cursor: pointer; `; const StyledAddIcon = styled(AddCircleOutline)` color: ${props => props.theme.palette.icon.color}; + &:hover { + color: ${props => props.theme.palette.icon.hover}; + } `; const Label = styled.label` @@ -36,4 +40,5 @@ const Label = styled.label` font-size: 0.875rem; color: ${props => props.theme.palette.common.black}; margin-inline-start: 8px; + cursor: pointer; `; diff --git a/src/webapp/components/avatar-card/AvatarCard.tsx b/src/webapp/components/avatar-card/AvatarCard.tsx index aca63efe..55a19945 100644 --- a/src/webapp/components/avatar-card/AvatarCard.tsx +++ b/src/webapp/components/avatar-card/AvatarCard.tsx @@ -23,6 +23,7 @@ export const AvatarCard: React.FC = React.memo( ); const StyledCard = styled(Card)` + width: 100%; display: flex; @media (max-width: 600px) { flex-direction: column; diff --git a/src/webapp/components/date-picker/DatePicker.tsx b/src/webapp/components/date-picker/DatePicker.tsx index 63e5df12..9402d505 100644 --- a/src/webapp/components/date-picker/DatePicker.tsx +++ b/src/webapp/components/date-picker/DatePicker.tsx @@ -9,7 +9,7 @@ import { IconCalendar24 } from "@dhis2/ui"; interface DatePickerProps { id: string; label?: string; - value?: Date; + value: Date | null; onChange: (value: Date | null) => void; helperText?: string; errorText?: string; @@ -56,6 +56,7 @@ export const DatePicker: React.FC = React.memo( const Container = styled.div` display: flex; flex-direction: column; + width: 100%; `; const Label = styled(InputLabel)` diff --git a/src/webapp/components/input-field/InputField.tsx b/src/webapp/components/input-field/InputField.tsx index ad623a0b..96d2cf4a 100644 --- a/src/webapp/components/input-field/InputField.tsx +++ b/src/webapp/components/input-field/InputField.tsx @@ -47,6 +47,7 @@ export const InputField: React.FC = React.memo( const Container = styled.div` display: flex; flex-direction: column; + width: 100%; `; const Label = styled(InputLabel)` diff --git a/src/webapp/components/layout/Layout.tsx b/src/webapp/components/layout/Layout.tsx index 1a383fcb..5e1b3d04 100644 --- a/src/webapp/components/layout/Layout.tsx +++ b/src/webapp/components/layout/Layout.tsx @@ -1,19 +1,26 @@ import React, { useState } from "react"; import styled from "styled-components"; import { useMediaQuery, useTheme } from "@material-ui/core"; +import { Menu } from "@material-ui/icons"; import { MainContent } from "./main-content/MainContent"; import { Button } from "../button/Button"; - interface LayoutProps { children?: React.ReactNode; title?: string; subtitle?: string; hideSideBarOptions?: boolean; + showCreateEvent?: boolean; } export const Layout: React.FC = React.memo( - ({ children, title = "", subtitle = "", hideSideBarOptions = false }) => { + ({ + children, + title = "", + subtitle = "", + hideSideBarOptions = false, + showCreateEvent = false, + }) => { const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); @@ -21,12 +28,15 @@ export const Layout: React.FC = React.memo( return ( {isSmallScreen && !hideSideBarOptions ? ( - + + + ) : ( - + {DEFAULT_SIDEBAR_OPTIONS.map(({ text, value }) => ( ))} - + )} ); @@ -68,7 +83,7 @@ const StyledText = styled(ListItemText)<{ selected?: boolean }>` const SideBarContainer = styled.div` display: flex; - width: 240px; + max-width: 245px; background-color: ${props => props.theme.palette.sidebar.background}; .MuiList-root { padding-block: 50px; @@ -81,3 +96,13 @@ const SideBarContainer = styled.div` padding-block: 4px; } `; + +const StyledList = styled(List)` + width: 245px; +`; + +const CreateEventContainer = styled.div` + margin-block-start: 50px; + margin-inline-start: 30px; + width: 245px; +`; diff --git a/src/webapp/components/multiple-selector/MultipleSelector.tsx b/src/webapp/components/multiple-selector/MultipleSelector.tsx index 4b304673..3533e4e1 100644 --- a/src/webapp/components/multiple-selector/MultipleSelector.tsx +++ b/src/webapp/components/multiple-selector/MultipleSelector.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; import styled from "styled-components"; import { Select, InputLabel, MenuItem, FormHelperText, Chip } from "@material-ui/core"; -import { IconChevronDown24, IconCross24 } from "@dhis2/ui"; +import { IconChevronDown24, IconCross16 } from "@dhis2/ui"; export type MultipleSelectorOption = { value: T; @@ -56,7 +56,11 @@ export const MultipleSelector: React.FC = React.memo( ); const handleDelete = useCallback( - (value: MultipleSelectorOption["value"]) => { + ( + event: React.MouseEvent, + value: MultipleSelectorOption["value"] + ) => { + event.stopPropagation(); onChange(selected?.filter(selection => selection !== value)); }, [onChange, selected] @@ -81,8 +85,9 @@ export const MultipleSelector: React.FC = React.memo( } - onDelete={() => handleDelete(value)} + deleteIcon={} + onDelete={event => handleDelete(event, value)} + onMouseDown={event => handleDelete(event, value)} /> ))} @@ -114,6 +119,7 @@ export const MultipleSelector: React.FC = React.memo( const Container = styled.div` display: flex; flex-direction: column; + width: 100%; `; const Label = styled(InputLabel)` @@ -130,15 +136,30 @@ const StyledFormHelperText = styled(FormHelperText)<{ error?: boolean }>` `; const StyledSelect = styled(Select)<{ error?: boolean }>` - padding-inline-start: 12px; - padding-inline-end: 6px; - padding-block: 10px; .MuiOutlinedInput-notchedOutline { border-color: ${props => props.error ? props.theme.palette.common.red600 : props.theme.palette.common.grey500}; } + .MuiSelect-root { + padding-inline-start: 12px; + padding-inline-end: 6px; + padding-block: 10px; + &:focus { + background-color: ${props => props.theme.palette.common.white}; + } + } `; const SelectedChip = styled(Chip)` margin-inline-end: 16px; + font-weight: 400; + font-size: 0.813rem; + padding-inline-end: 8px; + svg { + color: ${props => props.theme.palette.common.grey600}; + cursor: pointer; + &:hover { + color: ${props => props.theme.palette.common.grey900}; + } + } `; diff --git a/src/webapp/components/notice-box/NoticeBox.tsx b/src/webapp/components/notice-box/NoticeBox.tsx new file mode 100644 index 00000000..af7987b2 --- /dev/null +++ b/src/webapp/components/notice-box/NoticeBox.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { IconInfo24 } from "@dhis2/ui"; +import styled from "styled-components"; + +interface NoticeBoxProps { + title: string; + children: React.ReactNode; +} + +export const NoticeBox: React.FC = React.memo(({ title, children }) => { + return ( + + + + {title} + + {children} + + ); +}); + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + padding-inline: 16px; + padding-block: 12px; + gap: 8px; + background-color: ${props => props.theme.palette.common.blue050}; + border-radius: 3px; + border: 2px solid ${props => props.theme.palette.common.blue200}; +`; + +const TitleContainer = styled.div` + display: flex; + gap: 8px; + font-size: 0.875rem; + font-weight: 500; + color: ${props => props.theme.palette.text.primary}; + align-items: center; + svg { + color: ${props => props.theme.palette.common.blue900}; + } +`; + +const Content = styled.div` + font-size: 0.875rem; + font-weight: 400; + color: ${props => props.theme.palette.text.primary}; +`; diff --git a/src/webapp/components/profile-modal/ProfileModal.tsx b/src/webapp/components/profile-modal/ProfileModal.tsx new file mode 100644 index 00000000..4e7f96ce --- /dev/null +++ b/src/webapp/components/profile-modal/ProfileModal.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Modal, Avatar, CardContent, Card } from "@material-ui/core"; +import styled from "styled-components"; + +import i18n from "../../../utils/i18n"; +import { Button } from "../button/Button"; + +interface ProfileModalProps { + name: string; + children: React.ReactNode; + avatarSize?: "small" | "medium"; + alt?: string; + src?: string; + open: boolean; + onClose: () => void; +} + +export const ProfileModal: React.FC = React.memo( + ({ children, src, alt, open = false, onClose, name }) => { + return ( + + + {name} + + + + + {children} + +
+ +
+
+
+ ); + } +); + +const Content = styled.div` + display: flex; +`; + +const Name = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 1.25rem; + font-weight: 500; +`; + +const Footer = styled.div` + display: flex; + margin-block-start: 16px; +`; + +const StyledCard = styled(Card)` + width: 500px; + display: flex; + flex-direction: column; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 24px; + box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.1); +`; + +const AvatarContainer = styled.div` + padding-block: 20px; + padding-inline: 45px; + .MuiAvatar-root { + width: 121px; + height: 121px; + } +`; + +const StyledCardContent = styled(CardContent)` + width: 100%; +`; diff --git a/src/webapp/components/search-input/SearchInput.tsx b/src/webapp/components/search-input/SearchInput.tsx new file mode 100644 index 00000000..fc4c675a --- /dev/null +++ b/src/webapp/components/search-input/SearchInput.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { TextField } from "@material-ui/core"; +import { IconSearch24 } from "@dhis2/ui"; +import styled from "styled-components"; +import _ from "lodash"; + +import i18n from "../../../utils/i18n"; + +interface SearchInputProps { + value: string; + onChange: (event: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export const SearchInput: React.FC = React.memo( + ({ value, onChange, placeholder = "", disabled = false }) => { + const [stateValue, updateStateValue] = useState(value); + + useEffect(() => updateStateValue(value), [value]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const onChangeDebounced = useCallback( + _.debounce((value: string) => { + if (onChange) { + onChange(value); + } + }, 400), + [] + ); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + onChangeDebounced(value); + updateStateValue(value); + }, + [onChangeDebounced, updateStateValue] + ); + + const handleKeydown = useCallback((event: React.KeyboardEvent) => { + event.stopPropagation(); + }, []); + + return ( + + + + + + + ); + } +); + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + position: relative; +`; + +const IconContainer = styled.div<{ $disabled?: boolean }>` + height: 100%; + position: absolute; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + padding-inline-start: 24px; + padding-inline-end: 8px; + svg { + color: ${props => + props.$disabled ? props.theme.palette.common.grey3 : props.theme.palette.common.grey2}; + } +`; + +const StyledTextField = styled(TextField)<{ error?: boolean }>` + font-weight: 400; + font-size: 0.875rem; + color: ${props => props.theme.palette.common.grey1}; + .MuiFormHelperText-root { + color: ${props => + props.error ? props.theme.palette.common.red700 : props.theme.palette.common.grey700}; + } + .MuiOutlinedInput-notchedOutline { + border-color: ${props => props.theme.palette.common.grey4}; + } + .MuiInputBase-input { + background-color: ${props => props.theme.palette.common.background1}; + padding-inline-end: 12px; + padding-block: 10px; + padding-inline-start: 64px; + } +`; diff --git a/src/webapp/components/selector/Selector.tsx b/src/webapp/components/selector/Selector.tsx index f1d7cae3..e2969dfd 100644 --- a/src/webapp/components/selector/Selector.tsx +++ b/src/webapp/components/selector/Selector.tsx @@ -93,6 +93,7 @@ export const Selector: React.FC = React.memo( const Container = styled.div` display: flex; flex-direction: column; + width: 100%; `; const Label = styled(InputLabel)` @@ -109,11 +110,16 @@ const StyledFormHelperText = styled(FormHelperText)<{ error?: boolean }>` `; const StyledSelect = styled(Select)<{ error?: boolean }>` - padding-inline-start: 12px; - padding-inline-end: 6px; - padding-block: 10px; .MuiOutlinedInput-notchedOutline { border-color: ${props => props.error ? props.theme.palette.common.red600 : props.theme.palette.common.grey500}; } + .MuiSelect-root { + padding-inline-start: 12px; + padding-inline-end: 6px; + padding-block: 10px; + &:focus { + background-color: ${props => props.theme.palette.common.white}; + } + } `; diff --git a/src/webapp/components/stats-card/StatsCard.tsx b/src/webapp/components/stats-card/StatsCard.tsx new file mode 100644 index 00000000..576d8f68 --- /dev/null +++ b/src/webapp/components/stats-card/StatsCard.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { CardContent, Card } from "@material-ui/core"; +import styled from "styled-components"; + +interface StatsCardProps { + color?: "normal" | "green" | "red"; + stat: string; + pretitle?: string; + title: string; + subtitle?: string; + isPercentage?: boolean; + error?: boolean; +} + +export const StatsCard: React.FC = React.memo( + ({ + stat, + title, + subtitle, + pretitle, + color = "normal", + isPercentage = false, + error = false, + }) => { + return ( + + + {`${stat}${isPercentage ? " %" : ""}`} + {pretitle} + {title} + {subtitle} + + + ); + } +); + +const StyledCard = styled(Card)<{ $error?: boolean }>` + width: fit-content; + min-width: 220px; + max-width: 300px; + border-style: ${props => (props.$error ? "solid" : "none")}; + border-width: ${props => (props.$error ? "1px" : "0")}; + border-color: ${props => (props.$error ? props.theme.palette.stats.red : "unset")}; +`; + +const StyledCardContent = styled(CardContent)` + padding-inline: 66px; + padding-block: 32px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; +`; + +const Stat = styled.div<{ color: string }>` + color: ${props => props.theme.palette.stats[props.color]}; + font-size: 2em; + font-weight: 400; +`; + +const PreTitle = styled.span` + color: ${props => props.theme.palette.stats.pretitle}; + font-weight: 400; + font-size: 0.875rem; + text-align: center; +`; + +const Title = styled.span` + color: ${props => props.theme.palette.stats.title}; + font-weight: 700; + font-size: 1rem; + text-align: center; +`; + +const SubTitle = styled.span` + color: ${props => props.theme.palette.stats.subtitle}; + font-weight: 400; + font-size: 0.875rem; + text-align: center; +`; diff --git a/src/webapp/components/text-area/TextArea.tsx b/src/webapp/components/text-area/TextArea.tsx index d6e6b4e3..4e7afaa4 100644 --- a/src/webapp/components/text-area/TextArea.tsx +++ b/src/webapp/components/text-area/TextArea.tsx @@ -54,6 +54,7 @@ export const TextArea: React.FC = React.memo( const Container = styled.div` display: flex; flex-direction: column; + width: 100%; `; const Label = styled.label` diff --git a/src/webapp/pages/Router.tsx b/src/webapp/pages/Router.tsx index db0a2528..8665b9db 100644 --- a/src/webapp/pages/Router.tsx +++ b/src/webapp/pages/Router.tsx @@ -1,7 +1,6 @@ import React from "react"; import { HashRouter, Route, Switch } from "react-router-dom"; -import { LandingPage } from "./landing/LandingPage"; import { DashboardPage } from "./dashboard/DashboardPage"; import { EventTrackerPage } from "./event-tracker/EventTrackerPage"; import { IncidentActionPlanPage } from "./incident-action-plan/IncidentActionPlanPage"; @@ -16,33 +15,28 @@ export function Router() { return ( - } /> } /> - } /> - } /> - } /> + } /> } + path="/create-risk-assessment/:event" + render={() => } /> - } /> - } /> - } /> } + path="/incident-management-team-builder/:event" + render={() => } /> + } /> } + path="/incident-action-plan/:event" + render={() => } /> } /> - } /> + } /> {/* Default route */} - } /> + } /> ); diff --git a/src/webapp/pages/app/themes/dhis2.theme.ts b/src/webapp/pages/app/themes/dhis2.theme.ts index 1b69e1bf..7b08cf5f 100644 --- a/src/webapp/pages/app/themes/dhis2.theme.ts +++ b/src/webapp/pages/app/themes/dhis2.theme.ts @@ -90,6 +90,12 @@ const colors = { green: "#008B45", red: "#E4312B", orange: "#FABE5F", + grey1: "#2F2727", + grey2: "#4B4343", + grey3: "#8C8484", + grey4: "#bfbebe", + + background1: "#F5F5F5", }; const palette = { @@ -154,6 +160,7 @@ const palette = { }, icon: { color: colors.grey700, + hover: colors.grey900, }, header: { color: colors.green600, @@ -163,6 +170,14 @@ const palette = { black: colors.black, orange: colors.orange, }, + stats: { + green: colors.green, + red: colors.red, + normal: colors.green700, + title: colors.black, + subtitle: colors.grey3, + pretitle: colors.grey3, + }, }; export const muiTheme = createTheme({ diff --git a/src/webapp/pages/dashboard/Components.tsx b/src/webapp/pages/dashboard/Components.tsx new file mode 100644 index 00000000..84afcdf7 --- /dev/null +++ b/src/webapp/pages/dashboard/Components.tsx @@ -0,0 +1,201 @@ +import React, { useState } from "react"; +import styled from "styled-components"; + +import { Button } from "../../components/button/Button"; +import { AddNewOption } from "../../components/add-new-option/AddNewOption"; +import { AvatarCard } from "../../components/avatar-card/AvatarCard"; +import { DatePicker } from "../../components/date-picker/DatePicker"; +import { InputField } from "../../components/input-field/InputField"; +import { MultipleSelector } from "../../components/multiple-selector/MultipleSelector"; +import { NACheckbox } from "../../components/not-applicable-checkbox/NACheckbox"; +import { RadioButtonsGroup } from "../../components/radio-buttons-group/RadioButtonsGroup"; +import { Selector } from "../../components/selector/Selector"; +import { TextArea } from "../../components/text-area/TextArea"; +import { StatsCard } from "../../components/stats-card/StatsCard"; +import { SearchInput } from "../../components/search-input/SearchInput"; +import { NoticeBox } from "../../components/notice-box/NoticeBox"; +import { ProfileModal } from "../../components/profile-modal/ProfileModal"; + +export const Components: React.FC = React.memo(() => { + const [date, setDate] = useState(null); + const [select, setSelect] = useState(""); + const [multi, setMulti] = useState([]); + const [radio, setRadio] = useState(""); + const [naCheck, setNACheck] = useState(false); + const [area, setArea] = useState(""); + const [input, setInput] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [openProfileModal, setOpenProfileModal] = useState(false); + + return ( + <> + + + + + + + + + + + + Text + + + + + + setInput(event.target.value)} + /> + + + + + + + + + setRadio(event.target.value)} + options={[ + { value: "1", label: "value 1" }, + { value: "2", label: "value 2" }, + ]} + /> + + + + + +