diff --git a/CommBank-Server/Goal.cs b/CommBank-Server/Goal.cs new file mode 100644 index 00000000..81f01923 --- /dev/null +++ b/CommBank-Server/Goal.cs @@ -0,0 +1,32 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace CommBank.Models; + +public class Goal +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Icon { get; set; } + + public UInt64 TargetAmount { get; set; } = 0; + + public DateTime TargetDate { get; set; } + + public double Balance { get; set; } = 0.00; + + public DateTime Created { get; set; } = DateTime.Now; + + [BsonRepresentation(BsonType.ObjectId)] + public List? TransactionIds { get; set; } + + [BsonRepresentation(BsonType.ObjectId)] + public List? TagIds { get; set; } + + [BsonRepresentation(BsonType.ObjectId)] + public string? UserId { get; set; } +} \ No newline at end of file diff --git a/CommBank-Server/GoalManager.tsx b/CommBank-Server/GoalManager.tsx new file mode 100644 index 00000000..8ef78bb7 --- /dev/null +++ b/CommBank-Server/GoalManager.tsx @@ -0,0 +1,302 @@ +import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons' +import { + faDollarSign, + faSmile, + IconDefinition, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { BaseEmoji } from 'emoji-mart' +import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date' +import 'date-fns' +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { updateGoal as updateGoalApi } from '../../../api/lib' +import { Goal } from '../../../api/types' +import { selectGoalsMap, updateGoal as updateGoalRedux } from '../../../store/goalsSlice' +import { useAppDispatch, useAppSelector } from '../../../store/hooks' +import DatePicker from '../../components/DatePicker' +import EmojiPicker from '../../components/EmojiPicker' +import { Theme } from '../../components/Theme' +import { TransparentButton } from '../../components/TransparentButton' +import GoalIcon from './GoalIcon' + +type Props = { goal: Goal } + +export function GoalManager(props: Props) { + const dispatch = useAppDispatch() + + const goal = useAppSelector(selectGoalsMap)[props.goal.id] + + const [name, setName] = useState(null) + const [targetDate, setTargetDate] = useState(null) + const [targetAmount, setTargetAmount] = useState(null) + const [icon, setIcon] = useState(null) + const [emojiPickerIsOpen, setEmojiPickerIsOpen] = useState(false) + + useEffect(() => { + setName(props.goal.name) + setTargetDate(props.goal.targetDate) + setTargetAmount(props.goal.targetAmount) + setIcon(props.goal.icon) + }, [ + props.goal.id, + props.goal.name, + props.goal.targetDate, + props.goal.targetAmount, + props.goal.icon, + ]) + + useEffect(() => { + setName(goal.name) + }, [goal.name]) + + const hasIcon = () => icon != null + + const addIconOnClick = (event: React.MouseEvent) => { + event.stopPropagation() + setEmojiPickerIsOpen(true) + } + + const pickEmojiOnClick = ( + emoji: BaseEmoji, + event: React.MouseEvent + ) => { + event.stopPropagation() + + setIcon(emoji.native) + setEmojiPickerIsOpen(false) + + const updatedGoal: Goal = { + ...props.goal, + icon: emoji.native ?? props.goal.icon, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateNameOnChange = ( + event: React.ChangeEvent + ) => { + const nextName = event.target.value + setName(nextName) + + const updatedGoal: Goal = { + ...props.goal, + name: nextName, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateTargetAmountOnChange = ( + event: React.ChangeEvent + ) => { + const nextTargetAmount = parseFloat(event.target.value) + setTargetAmount(nextTargetAmount) + + const updatedGoal: Goal = { + ...props.goal, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: nextTargetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const pickDateOnChange = (date: MaterialUiPickersDate) => { + if (date != null) { + setTargetDate(date) + + const updatedGoal: Goal = { + ...props.goal, + name: name ?? props.goal.name, + targetDate: date, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + } + + return ( + + + + + + + + + Add icon + + + + event.stopPropagation()} + > + + + + + + + + + + + + + + + + + + + + + + + {props.goal.balance} + + + + + + + + {new Date(props.goal.created).toLocaleDateString()} + + + + + ) +} + +type FieldProps = { + name: string + icon: IconDefinition +} + +type AddIconButtonContainerProps = { + shouldShow: boolean +} + +type GoalIconContainerProps = { + shouldShow: boolean +} + +type EmojiPickerContainerProps = { + isOpen: boolean + hasIcon: boolean +} + +const Field = (props: FieldProps) => ( + + + {props.name} + +) + +const GoalManagerContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + height: 100%; + width: 100%; + position: relative; +` + +const AddIconButtonContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; +` + +const AddIconButtonText = styled.span` + font-size: 1.8rem; + margin-left: 0.5rem; +` + +const GoalIconContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; +` + +const EmojiPickerContainer = styled.div` + display: ${(props) => (props.isOpen ? 'flex' : 'none')}; + position: absolute; + top: ${(props) => (props.hasIcon ? '10rem' : '2rem')}; + left: 0; +` + +const Group = styled.div` + display: flex; + flex-direction: row; + width: 100%; + margin-top: 1.25rem; + margin-bottom: 1.25rem; +` + +const NameInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 4rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const FieldName = styled.h1` + font-size: 1.8rem; + margin-left: 1rem; + color: rgba(174, 174, 174, 1); + font-weight: normal; +` + +const FieldContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 20rem; + + svg { + color: rgba(174, 174, 174, 1); + } +` + +const StringValue = styled.h1` + font-size: 1.8rem; + font-weight: bold; +` + +const StringInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 1.8rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const Value = styled.div` + margin-left: 2rem; +` \ No newline at end of file diff --git a/CommBank-Server/Models/Goal.cs b/CommBank-Server/Models/Goal.cs index 77ff1ad5..81f01923 100644 --- a/CommBank-Server/Models/Goal.cs +++ b/CommBank-Server/Models/Goal.cs @@ -11,6 +11,8 @@ public class Goal public string? Name { get; set; } + public string? Icon { get; set; } + public UInt64 TargetAmount { get; set; } = 0; public DateTime TargetDate { get; set; } diff --git a/CommBank-Server/Models/GoalManager.tsx b/CommBank-Server/Models/GoalManager.tsx new file mode 100644 index 00000000..8ef78bb7 --- /dev/null +++ b/CommBank-Server/Models/GoalManager.tsx @@ -0,0 +1,302 @@ +import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons' +import { + faDollarSign, + faSmile, + IconDefinition, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { BaseEmoji } from 'emoji-mart' +import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date' +import 'date-fns' +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { updateGoal as updateGoalApi } from '../../../api/lib' +import { Goal } from '../../../api/types' +import { selectGoalsMap, updateGoal as updateGoalRedux } from '../../../store/goalsSlice' +import { useAppDispatch, useAppSelector } from '../../../store/hooks' +import DatePicker from '../../components/DatePicker' +import EmojiPicker from '../../components/EmojiPicker' +import { Theme } from '../../components/Theme' +import { TransparentButton } from '../../components/TransparentButton' +import GoalIcon from './GoalIcon' + +type Props = { goal: Goal } + +export function GoalManager(props: Props) { + const dispatch = useAppDispatch() + + const goal = useAppSelector(selectGoalsMap)[props.goal.id] + + const [name, setName] = useState(null) + const [targetDate, setTargetDate] = useState(null) + const [targetAmount, setTargetAmount] = useState(null) + const [icon, setIcon] = useState(null) + const [emojiPickerIsOpen, setEmojiPickerIsOpen] = useState(false) + + useEffect(() => { + setName(props.goal.name) + setTargetDate(props.goal.targetDate) + setTargetAmount(props.goal.targetAmount) + setIcon(props.goal.icon) + }, [ + props.goal.id, + props.goal.name, + props.goal.targetDate, + props.goal.targetAmount, + props.goal.icon, + ]) + + useEffect(() => { + setName(goal.name) + }, [goal.name]) + + const hasIcon = () => icon != null + + const addIconOnClick = (event: React.MouseEvent) => { + event.stopPropagation() + setEmojiPickerIsOpen(true) + } + + const pickEmojiOnClick = ( + emoji: BaseEmoji, + event: React.MouseEvent + ) => { + event.stopPropagation() + + setIcon(emoji.native) + setEmojiPickerIsOpen(false) + + const updatedGoal: Goal = { + ...props.goal, + icon: emoji.native ?? props.goal.icon, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateNameOnChange = ( + event: React.ChangeEvent + ) => { + const nextName = event.target.value + setName(nextName) + + const updatedGoal: Goal = { + ...props.goal, + name: nextName, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const updateTargetAmountOnChange = ( + event: React.ChangeEvent + ) => { + const nextTargetAmount = parseFloat(event.target.value) + setTargetAmount(nextTargetAmount) + + const updatedGoal: Goal = { + ...props.goal, + name: name ?? props.goal.name, + targetDate: targetDate ?? props.goal.targetDate, + targetAmount: nextTargetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + + const pickDateOnChange = (date: MaterialUiPickersDate) => { + if (date != null) { + setTargetDate(date) + + const updatedGoal: Goal = { + ...props.goal, + name: name ?? props.goal.name, + targetDate: date, + targetAmount: targetAmount ?? props.goal.targetAmount, + } + + dispatch(updateGoalRedux(updatedGoal)) + updateGoalApi(props.goal.id, updatedGoal) + } + } + + return ( + + + + + + + + + Add icon + + + + event.stopPropagation()} + > + + + + + + + + + + + + + + + + + + + + + + + {props.goal.balance} + + + + + + + + {new Date(props.goal.created).toLocaleDateString()} + + + + + ) +} + +type FieldProps = { + name: string + icon: IconDefinition +} + +type AddIconButtonContainerProps = { + shouldShow: boolean +} + +type GoalIconContainerProps = { + shouldShow: boolean +} + +type EmojiPickerContainerProps = { + isOpen: boolean + hasIcon: boolean +} + +const Field = (props: FieldProps) => ( + + + {props.name} + +) + +const GoalManagerContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + height: 100%; + width: 100%; + position: relative; +` + +const AddIconButtonContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; +` + +const AddIconButtonText = styled.span` + font-size: 1.8rem; + margin-left: 0.5rem; +` + +const GoalIconContainer = styled.div` + display: ${(props) => (props.shouldShow ? 'flex' : 'none')}; +` + +const EmojiPickerContainer = styled.div` + display: ${(props) => (props.isOpen ? 'flex' : 'none')}; + position: absolute; + top: ${(props) => (props.hasIcon ? '10rem' : '2rem')}; + left: 0; +` + +const Group = styled.div` + display: flex; + flex-direction: row; + width: 100%; + margin-top: 1.25rem; + margin-bottom: 1.25rem; +` + +const NameInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 4rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const FieldName = styled.h1` + font-size: 1.8rem; + margin-left: 1rem; + color: rgba(174, 174, 174, 1); + font-weight: normal; +` + +const FieldContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 20rem; + + svg { + color: rgba(174, 174, 174, 1); + } +` + +const StringValue = styled.h1` + font-size: 1.8rem; + font-weight: bold; +` + +const StringInput = styled.input` + display: flex; + background-color: transparent; + outline: none; + border: none; + font-size: 1.8rem; + font-weight: bold; + color: ${({ theme }: { theme: Theme }) => theme.text}; +` + +const Value = styled.div` + margin-left: 2rem; +` \ No newline at end of file