diff --git a/Gemfile b/Gemfile index ea22cf4..9dc5ec6 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,6 @@ group :development, :test do gem 'factory_bot_rails' gem 'faker' gem 'rspec-rails' - gem 'rubocop' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 6ce5a1b..8e8cb9d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,7 +75,6 @@ GEM minitest (>= 5.1) mutex_m tzinfo (~> 2.0) - ast (2.4.2) base64 (0.2.0) bcrypt (3.1.20) bigdecimal (3.1.5) @@ -108,9 +107,7 @@ GEM irb (1.11.0) rdoc reline (>= 0.3.8) - json (2.7.1) jwt (2.7.1) - language_server-protocol (3.17.0.3) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -142,10 +139,6 @@ GEM nokogiri (1.16.0-x86_64-linux) racc (~> 1.4) openssl (3.2.0) - parallel (1.24.0) - parser (3.2.2.4) - ast (~> 2.4.1) - racc psych (5.1.2) stringio puma (5.6.7) @@ -190,15 +183,12 @@ GEM rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) - rainbow (3.1.1) rake (13.1.0) rdoc (6.6.2) psych (>= 4.0.0) redis (4.8.1) - regexp_parser (2.8.3) reline (0.4.1) io-console (~> 0.5) - rexml (3.2.6) rspec-core (3.12.2) rspec-support (~> 3.12.0) rspec-expectations (3.12.3) @@ -216,20 +206,6 @@ GEM rspec-mocks (~> 3.12) rspec-support (~> 3.12) rspec-support (3.12.1) - rubocop (1.59.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.2.2.4) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.30.0) - parser (>= 3.2.1.0) - ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) stringio (3.1.0) thor (1.3.0) @@ -238,7 +214,6 @@ GEM concurrent-ruby (~> 1.0) tzinfo-data (1.2023.4) tzinfo (>= 1.0.0) - unicode-display_width (2.5.0) webrick (1.8.1) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -264,7 +239,6 @@ DEPENDENCIES rails (~> 7.1.2, >= 7.0.4.3) redis (~> 4.0) rspec-rails - rubocop tzinfo-data RUBY VERSION diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index d9fddd6..9cc9ae4 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -7,11 +7,12 @@ module AuthHelper def self.generate_token_by_type(type, payload) - expiry = if type == :REFRESH - Time.now.to_i + 2_592_000 - else - expiry = Time.now.to_i + 900 - end + expiry = + if type == :REFRESH + Time.now.to_i + 2_592_000 + else + expiry = Time.now.to_i + 900 + end payload[:exp] = expiry JWT.encode(payload, ENV["#{type}_TOKEN_SECRET"], 'HS256') @@ -46,17 +47,11 @@ def self.generate_cookie_hash(refresh_token) secure: true, httponly: true, same_site: Rails.env.development? ? :None : :Strict, - path: Constants::API_PATHS[:SESSIONS_TOKEN] + path: Constants::API_PATHS[:SESSIONS_TOKEN], } end def self.generate_deleted_cookie - { - value: nil, - expires: Time.at(0), - secure: true, - httponly: true, - same_site: Rails.env.development? ? :None : :Strict - } + { value: nil, expires: Time.at(0), secure: true, httponly: true, same_site: Rails.env.development? ? :None : :Strict } end end diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 8f121bb..a292b20 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -19,7 +19,7 @@ import { ToastDefaultOptions } from '@/utils/constants'; const InteriorLayout = () => ( - + diff --git a/client/src/assets/images/expenses-calculator-icon.svg b/client/src/assets/images/expenses-calculator-icon.svg new file mode 100644 index 0000000..bbe615b --- /dev/null +++ b/client/src/assets/images/expenses-calculator-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/images/expenses-calendar-icon.svg b/client/src/assets/images/expenses-calendar-icon.svg new file mode 100644 index 0000000..de15879 --- /dev/null +++ b/client/src/assets/images/expenses-calendar-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/images/expenses-change-icon.svg b/client/src/assets/images/expenses-change-icon.svg new file mode 100644 index 0000000..7f2f46c --- /dev/null +++ b/client/src/assets/images/expenses-change-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/assets/images/expenses-money-icon.svg b/client/src/assets/images/expenses-money-icon.svg new file mode 100644 index 0000000..f660586 --- /dev/null +++ b/client/src/assets/images/expenses-money-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/src/components/Avatar.tsx b/client/src/components/Avatar.tsx index 7e37994..87634b0 100644 --- a/client/src/components/Avatar.tsx +++ b/client/src/components/Avatar.tsx @@ -1,7 +1,52 @@ -import { Box } from '@chakra-ui/react'; +import { Box, Image, PlacementWithLogical, Tooltip } from '@chakra-ui/react'; -const Avatar = () => { - return ; +import { User } from '@/utils/types'; +import userImage from '@/assets/images/user-image.svg'; +import { UserStatusColorMappings } from '@/utils/constants'; + +const Avatar = ({ user, tooltipPlacement }: { user: User; tooltipPlacement: string }) => { + const generateReadableStatus = () => { + if (user.status) { + const spacedStatus = user.status.replace('_', ' '); + return spacedStatus.charAt(0).toUpperCase() + spacedStatus.slice(1); + } else { + return 'Unknown'; + } + }; + + return ( + + + + + + + {user.status === 'do_not_disturb' && ( + + )} + + + + ); }; export default Avatar; diff --git a/client/src/components/ExpensesRow.tsx b/client/src/components/ExpensesRow.tsx new file mode 100644 index 0000000..aac3197 --- /dev/null +++ b/client/src/components/ExpensesRow.tsx @@ -0,0 +1,8 @@ +import { Expense } from '@/utils/types'; +import { Box } from '@chakra-ui/react'; + +const ExpensesRow = ({ expense }: { expense: Expense }) => { + return ; +}; + +export default ExpensesRow; diff --git a/client/src/components/Gateway.tsx b/client/src/components/Gateway.tsx index 4f1de17..c552b3c 100644 --- a/client/src/components/Gateway.tsx +++ b/client/src/components/Gateway.tsx @@ -17,6 +17,7 @@ const Gateway = ({ children }: { children: ReactElement }) => { const session = useSelector((state: RootState) => state.session); const currentUser = useSelector((state: RootState) => state.user); + const hideout = useSelector((state: RootState) => state.hideout); useGetUserQuery(session.userID!, { skip: !session.isLoggedIn || currentUser.id !== null }); useGetHideoutQuery(currentUser.hideoutID!, { skip: currentUser.hideoutID === null }); @@ -34,7 +35,11 @@ const Gateway = ({ children }: { children: ReactElement }) => { }, [dispatch, data, isLoading, isSuccess, isError]); const getRenderContent = () => { - if (session.isLoggedIn === null || (session.isLoggedIn && currentUser.id === null)) { + if ( + session.isLoggedIn === null || + (session.isLoggedIn && currentUser.id === null) || + (currentUser.hideoutID !== null && hideout.id === null) + ) { return ( @@ -45,7 +50,11 @@ const Gateway = ({ children }: { children: ReactElement }) => { } }; - return session.isLoggedIn && currentUser.id !== null ? : getRenderContent(); + return session.isLoggedIn && currentUser.id !== null && (currentUser.hideoutID === null || hideout.id !== null) ? ( + + ) : ( + getRenderContent() + ); }; export default Gateway; diff --git a/client/src/components/index.ts b/client/src/components/index.ts index 52ca266..bc1d0d3 100644 --- a/client/src/components/index.ts +++ b/client/src/components/index.ts @@ -2,3 +2,5 @@ export { default as Spinner } from '@/components/Spinner'; export { default as SidebarTile } from '@/components/SidebarTile'; export { default as Gateway } from '@/components/Gateway'; export { default as Protected } from '@/components/Protected'; +export { default as Avatar } from '@/components/Avatar'; +export { default as ExpensesRow } from '@/components/ExpensesRow'; diff --git a/client/src/config/theme.css b/client/src/config/theme.css new file mode 100644 index 0000000..f2cb1f7 --- /dev/null +++ b/client/src/config/theme.css @@ -0,0 +1,3 @@ +.chakra-checkbox__control { + border-radius: 6px !important; +} diff --git a/client/src/config/theme.js b/client/src/config/theme.js index 2139918..52e25dd 100644 --- a/client/src/config/theme.js +++ b/client/src/config/theme.js @@ -1,5 +1,6 @@ import { extendTheme } from '@chakra-ui/react'; import '@/config/fonts.css'; +import '@/config/theme.css'; const theme = extendTheme({ colors: { @@ -139,6 +140,17 @@ const theme = extendTheme({ }, }, }, + Tooltip: { + baseStyle: { + fontSize: '14px', + fontFamily: 'Hellix', + backgroundColor: 'gray.900', + fontWeight: 500, + color: 'white', + padding: '4px 10px 4px 10px', + borderRadius: '8px', + }, + }, }, }); diff --git a/client/src/containers/ExpensesStatsDisplay.tsx b/client/src/containers/ExpensesStatsDisplay.tsx new file mode 100644 index 0000000..9cfe6c1 --- /dev/null +++ b/client/src/containers/ExpensesStatsDisplay.tsx @@ -0,0 +1,136 @@ +import { Box, Image, Text, Heading, Skeleton } from '@chakra-ui/react'; +import { useSelector } from 'react-redux'; +import dayjs from 'dayjs'; + +import moneyIcon from '@/assets/images/expenses-money-icon.svg'; +import calculatorIcon from '@/assets/images/expenses-calculator-icon.svg'; +import calendarIcon from '@/assets/images/expenses-calendar-icon.svg'; +import changeIcon from '@/assets/images/expenses-change-icon.svg'; +import { Expense, RootState } from '@/utils/types'; + +const ExpensesStatsDisplay = ({ isLoading }: { isLoading: boolean }) => { + const expenses = useSelector((state: RootState) => state.expenses); + const currentDate = dayjs(); + + const sumExpensesAmounts = (expenses: Expense[]) => + expenses.reduce((accumulator: number, current: Expense) => accumulator + current.amount!, 0); + + const totalActiveExpenses = sumExpensesAmounts(Object.values(expenses).filter((expense: Expense) => expense.active)); + const activeExpensesCount = Object.keys(expenses).length; + const remainingDaysInMonth = currentDate.daysInMonth() - currentDate.date(); + + const calculateMonthlyExpenseChange = () => { + const startOfPrevMonth = currentDate.subtract(1, 'month').startOf('month'); + const endOfPrevMonth = currentDate.subtract(1, 'month').endOf('month'); + + const totalPreviousMonthExpenses = sumExpensesAmounts( + Object.values(expenses).filter( + (expense: Expense) => + expense.dueDate !== null && + dayjs(expense.dueDate).isAfter(startOfPrevMonth) && + dayjs(expense.dueDate).isBefore(endOfPrevMonth), + ), + ); + + const expenseChange = totalActiveExpenses - totalPreviousMonthExpenses; + return `${expenseChange > 0 ? '+' : '-'}${expenseChange}`; + }; + + const expensesStatisticsData = [ + { + imageSrc: moneyIcon, + label: 'Total Active Expenses', + numerical: totalActiveExpenses, + suffix: 'CAD', + showDollarSign: true, + }, + { + imageSrc: calculatorIcon, + label: '# of Active Expenses', + numerical: activeExpensesCount, + suffix: 'active', + showDollarSign: false, + }, + { + imageSrc: calendarIcon, + label: 'Remaining Billing Period', + numerical: remainingDaysInMonth, + suffix: 'days left', + showDollarSign: false, + }, + { + imageSrc: changeIcon, + label: 'Monthly Expense Change', + numerical: calculateMonthlyExpenseChange(), + suffix: 'CAD', + showDollarSign: true, + }, + ]; + + const generateStatisticBox = ({ + imageSrc, + label, + numerical, + suffix, + showDollarSign, + }: { + imageSrc: string; + label: string; + numerical: number | string; + suffix: string; + showDollarSign: boolean; + }) => ( + + + + + {label} + + + + + {showDollarSign && ( + + $ + + )} + + {numerical} + + + {suffix} + + + + + + + ); + + return ( + + {expensesStatisticsData.map((statistic) => generateStatisticBox(statistic))} + + ); +}; + +export default ExpensesStatsDisplay; diff --git a/client/src/containers/ExpensesTable.tsx b/client/src/containers/ExpensesTable.tsx new file mode 100644 index 0000000..d15a48f --- /dev/null +++ b/client/src/containers/ExpensesTable.tsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import { FaChevronDown } from 'react-icons/fa'; +import { MdOutlineClearAll, MdOutlineHorizontalRule, MdOutlinePendingActions } from 'react-icons/md'; +import { useSelector } from 'react-redux'; +import { + Box, + Menu, + MenuButton, + MenuList, + MenuItem, + Button, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tr, + Checkbox, +} from '@chakra-ui/react'; + +import { ExpensesViewModes } from '@/utils/constants'; +import { Expense, ExpensesReduxStore, RootState } from '@/utils/types'; +import { IoCheckmarkDone } from 'react-icons/io5'; + +const ExpensesTable = ({ isLoading }: { isLoading: boolean }) => { + const expenses: ExpensesReduxStore = useSelector((state: RootState) => state.expenses); + const [expensesViewMode, setExpensesViewMode] = useState(ExpensesViewModes.ACTIVE); + const [selectedExpenses, setSelectedExpenses] = useState([]); + + const retrieveExpensesByMode = () => { + const expenseList: Expense[] = Object.values(expenses); + if (expensesViewMode === ExpensesViewModes.ALL) return expenseList; + return expenseList.filter((expense) => + expensesViewMode === ExpensesViewModes.ACTIVE ? expense.active : !expense.active, + ); + }; + + const expensesByMode = retrieveExpensesByMode(); + + const onIndeterminateCheckboxChange = (e: React.ChangeEvent) => { + if (e.target.checked) setSelectedExpenses(expensesByMode.map(({ id }) => id)); + else setSelectedExpenses([]); + }; + + const onRowCheckboxChange = (e: React.ChangeEvent, expenseID: number) => { + if (e.target.checked) setSelectedExpenses([...selectedExpenses, expenseID]); + else setSelectedExpenses([...selectedExpenses.filter((id) => id !== expenseID)]); + }; + + return ( + + + + } + padding='0' + height='40px' + marginLeft='34px' + > + {expensesViewMode} Expenses + + + } + onClick={() => setExpensesViewMode(ExpensesViewModes.ACTIVE)} + > + Active Expenses + + } + fontWeight='500' + fontFamily='Hellix' + marginBottom='5px' + onClick={() => setExpensesViewMode(ExpensesViewModes.INACTIVE)} + > + Inactive Expenses + + } + fontWeight='500' + fontFamily='Hellix' + onClick={() => setExpensesViewMode(ExpensesViewModes.ALL)} + > + All Expenses + + + + + + + + + + + + + + + + + + + + {expensesByMode.map((expense) => ( + + + + + + + + + + ))} + +
+ + NameAmountDue DateDebtorCreditorActions
+ onRowCheckboxChange(event, expense.id)} + isChecked={selectedExpenses.includes(expense.id)} + /> + {expense.name}{expense.amount}{expense.dueDate}{expense.debtorID}{expense.creditorID}Actions
+
+
+
+ ); +}; + +export default ExpensesTable; diff --git a/client/src/containers/LoginForm.tsx b/client/src/containers/LoginForm.tsx index 9823843..9642bce 100644 --- a/client/src/containers/LoginForm.tsx +++ b/client/src/containers/LoginForm.tsx @@ -101,7 +101,7 @@ const LoginForm = () => { width='100%' variant='gradient400' mt='25px' - isLoading={createSessionResult.isLoading} + isLoading={createSessionResult.isLoading && loadingProvider === null} isDisabled={createSessionResult.isLoading || loadingProvider !== null} type='submit' onClick={() => catchify(handleStandardAuth)} diff --git a/client/src/containers/TopBar.tsx b/client/src/containers/TopBar.tsx index 4813161..49cc074 100644 --- a/client/src/containers/TopBar.tsx +++ b/client/src/containers/TopBar.tsx @@ -3,10 +3,10 @@ import { IoNotifications } from 'react-icons/io5'; import { FaRegLifeRing } from 'react-icons/fa'; import treeIcon from '@/assets/images/tree-icon.svg'; -import userImage from '@/assets/images/user-image.svg'; import { useCurrentTab } from '@/utils/hooks'; import { useSelector } from 'react-redux'; import { RootState } from '@/utils/types'; +import { Avatar } from '@/components'; const TopBar = () => { const currentTab = useCurrentTab(); @@ -24,6 +24,7 @@ const TopBar = () => { pr='1.06rem' borderBottomWidth='0.265px' borderColor='gray.200' + backgroundColor='white' > @@ -54,6 +55,7 @@ const TopBar = () => { borderRadius='10px' padding='6px' backgroundColor='gray.200' + _hover={{ backgroundColor: 'gray.300', cursor: 'pointer' }} /> { borderRadius='10px' padding='6px' backgroundColor='gray.200' + _hover={{ backgroundColor: 'gray.300', cursor: 'pointer' }} /> - - - +
); diff --git a/client/src/containers/index.ts b/client/src/containers/index.ts index f72acdc..d01986a 100644 --- a/client/src/containers/index.ts +++ b/client/src/containers/index.ts @@ -6,3 +6,5 @@ export { default as Sidebar } from '@/containers/Sidebar'; export { default as LoginForm } from '@/containers/LoginForm'; export { default as OnboardingJoinForm } from '@/containers/OnboardingJoinForm'; export { default as OnboardingCreateForm } from '@/containers/OnboardingCreateForm'; +export { default as ExpensesStatsDisplay } from '@/containers/ExpensesStatsDisplay'; +export { default as ExpensesTable } from '@/containers/ExpensesTable'; diff --git a/client/src/pages/ExpensesPage.tsx b/client/src/pages/ExpensesPage.tsx index 26dbb86..dfa32f2 100644 --- a/client/src/pages/ExpensesPage.tsx +++ b/client/src/pages/ExpensesPage.tsx @@ -1,7 +1,27 @@ import { Box } from '@chakra-ui/react'; +import { useSelector } from 'react-redux'; + +import { RootState } from '@/utils/types'; +import { ExpensesStatsDisplay, ExpensesTable } from '@/containers'; +import { useGetAllExpensesByHideoutQuery } from '@/redux/api/expenses'; const ExpensesPage = () => { - return ; + const hideout = useSelector((state: RootState) => state.hideout); + const { isLoading } = useGetAllExpensesByHideoutQuery(hideout.id!); + + return ( + + + + + ); }; export default ExpensesPage; diff --git a/client/src/utils/constants.ts b/client/src/utils/constants.ts index fc1ce35..e4b5d9d 100644 --- a/client/src/utils/constants.ts +++ b/client/src/utils/constants.ts @@ -203,3 +203,16 @@ export const CreateHideoutToastMessages = { export enum CustomErrorMessages { SignupStandardAuthValidationMessage = 'Invalid email or insecure password (passwords must have both cases, numbers and special characters).', } + +export const UserStatusColorMappings = { + available: 'green.400', + busy: 'yellow.500', + away: 'gray.500', + do_not_disturb: 'red.500', +}; + +export enum ExpensesViewModes { + ALL = 'All', + ACTIVE = 'Active', + INACTIVE = 'Inactive', +} diff --git a/client/src/utils/types.ts b/client/src/utils/types.ts index e801b82..0f046ef 100644 --- a/client/src/utils/types.ts +++ b/client/src/utils/types.ts @@ -10,10 +10,10 @@ export type AppDispatch = typeof store.dispatch; // Model & API Request / Response Types export interface User { - id: number | null; - email: string | null; - firstName: string | null; - lastName: string | null; + id: number; + email: string; + firstName: string; + lastName: string; color: 'red' | 'blue' | 'purple' | 'yellow' | 'green' | 'orange' | null; hideoutID: number | null; status: 'available' | 'busy' | 'away' | 'do_not_disturb' | null; @@ -45,9 +45,9 @@ export interface APIResponseError { } export interface Hideout { - id: number | null; - name: string | null; - ownerID: number | null; + id: number; + name: string; + ownerID: number; joinCode: string | null; } @@ -64,13 +64,13 @@ export interface HideoutsAPIResponse { } export interface Chore { - id: number | null; - name: string | null; + id: number; + name: string; description: string | null; dueDate: string | null; assigneeID: number | null; hideoutID: number | null; - status: 'backlog' | 'in_progress' | 'completed' | null; + status: 'backlog' | 'in_progress' | 'completed'; } export interface ChoresAPIRequest { @@ -97,15 +97,15 @@ export interface ChoresReduxStore { } export interface Expense { - id: number | null; - name: string | null; - amount: number | null; + id: number; + name: string; + amount: number; dueDate: string | null; debtorID: number | null; creditorID: number | null; hideoutID: number | null; comments: string | null; - active: boolean | null; + active: boolean; } export interface ExpensesAPIRequest { diff --git a/package.json b/package.json index 369e7af..8ae1388 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,9 @@ "devDependencies": { "@prettier/plugin-ruby": "^3.2.2", "prettier": "^2.8.8" + }, + "dependencies": { + "ag-grid-react": "^32.3.2", + "dayjs": "^1.11.12" } } diff --git a/spec/factories/user.rb b/spec/factories/user.rb index 8bda367..cf95f47 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -1,4 +1,5 @@ require 'faker' +require 'bcrypt' FactoryBot.define do factory :user do @@ -7,7 +8,7 @@ last_name { Faker::Name.last_name } hideout { association(:hideout) } color { %w[red blue purple yellow green orange].sample } - password { 'password' } + password { BCrypt::Password.create('password123') } status { %w[available away busy do_not_disturb].sample } end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 61b4c23..a4c2abc 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -61,3 +61,5 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end +# In rails_helper.rb or spec_helper.rb +Rails.application.configure { config.action_dispatch.show_exceptions = :none }