diff --git a/backend/src/entity/DashboardSettings.ts b/backend/src/entity/DashboardSettings.ts index 44a6cd78..8cd9ae83 100644 --- a/backend/src/entity/DashboardSettings.ts +++ b/backend/src/entity/DashboardSettings.ts @@ -1,35 +1,17 @@ -import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; import { User } from './User'; -export enum DashboardWidgets { - THIS_MOTH = 'THIS_MONTH', - THIS_YEAR = 'THIS_YEAR', - CURRENT_STATUS = 'CURRENT_STATUS', - LATEST_RECORDS = 'LATEST_RECORDS', - MONTHLY_CATEGORY = 'MONTHLY_CATEGORY', - QUICK_ACTIONS = 'QUICK_ACTIONS', -} - @Entity() export class DashboardSettings { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ - type: 'simple-array', - enum: DashboardWidgets, - default: [ - DashboardWidgets.THIS_MOTH, - DashboardWidgets.THIS_YEAR, - DashboardWidgets.CURRENT_STATUS, - DashboardWidgets.LATEST_RECORDS, - DashboardWidgets.MONTHLY_CATEGORY, - DashboardWidgets.QUICK_ACTIONS, - ], - }) - enabledWidgets: DashboardWidgets[]; + @Column() + widget: string; + @Column() + order: number; @Column() ownerUsername: string; - @OneToOne(() => User) + @ManyToOne(() => User) @JoinColumn({ name: 'ownerUsername' }) owner: User; } diff --git a/backend/src/index.ts b/backend/src/index.ts index f88d6a76..ee694973 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ app.keys = [config.secret]; app.use(session(app)); import passport from 'koa-passport'; +import settingsRouter from './settings/services/settingsRouter'; app.use(passport.initialize()); app.use(passport.session()); @@ -60,6 +61,7 @@ router.use('/records', recordRouter); router.use('/wallets', walletRouter); router.use('/categories', categoryRouter); router.use('/statistics', statisticsRouter); +router.use('/settings', settingsRouter); app.use(router.allowedMethods({ throw: true })); app.use(router.routes()); diff --git a/backend/src/migration/1636302462708-AddDashboardSettings.ts b/backend/src/migration/1636302462708-AddDashboardSettings.ts deleted file mode 100644 index 36b8fd14..00000000 --- a/backend/src/migration/1636302462708-AddDashboardSettings.ts +++ /dev/null @@ -1,22 +0,0 @@ -// import { MigrationInterface, QueryRunner } from 'typeorm'; - -// export class AddDashboardSettings1636302462708 implements MigrationInterface { -// name = 'AddDashboardSettings1636302462708'; - -// public async up(queryRunner: QueryRunner): Promise { -// await queryRunner.query( -// `CREATE TABLE \`dashboard_settings\` (\`id\` char(36) NOT NULL, \`enabledWidgets\` text ('THIS_MONTH', 'THIS_YEAR', 'CURRENT_STATUS', 'LATEST_RECORDS', 'MONTHLY_CATEGORY', 'QUICK_ACTIONS') NOT NULL DEFAULT THIS_MONTH,THIS_YEAR,CURRENT_STATUS,LATEST_RECORDS,MONTHLY_CATEGORY,QUICK_ACTIONS, \`ownerUsername\` varchar(255) NOT NULL, UNIQUE INDEX \`REL_44eca2a73ba5b456b2a9509e39\` (\`ownerUsername\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, -// ); -// await queryRunner.query( -// `ALTER TABLE \`dashboard_settings\` ADD CONSTRAINT \`FK_44eca2a73ba5b456b2a9509e390\` FOREIGN KEY (\`ownerUsername\`) REFERENCES \`user\`(\`username\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, -// ); -// } - -// public async down(queryRunner: QueryRunner): Promise { -// await queryRunner.query( -// `ALTER TABLE \`dashboard_settings\` DROP FOREIGN KEY \`FK_44eca2a73ba5b456b2a9509e390\``, -// ); -// await queryRunner.query(`DROP INDEX \`REL_44eca2a73ba5b456b2a9509e39\` ON \`dashboard_settings\``); -// await queryRunner.query(`DROP TABLE \`dashboard_settings\``); -// } -// } diff --git a/backend/src/migration/1648214448116-DashboardSettings.ts b/backend/src/migration/1648214448116-DashboardSettings.ts new file mode 100644 index 00000000..6056167b --- /dev/null +++ b/backend/src/migration/1648214448116-DashboardSettings.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { DEFAULT_WIDGETS } from '../settings/models/Settings'; +import { services } from '../shared/services/services'; + +export class DashboardSettings1648214448116 implements MigrationInterface { + name = 'DashboardSettings1648214448116'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`dashboard_settings\` (\`id\` varchar(36) NOT NULL, \`widget\` varchar(255) NOT NULL, \`order\` int NOT NULL, \`ownerUsername\` varchar(255) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`dashboard_settings\` ADD CONSTRAINT \`FK_44eca2a73ba5b456b2a9509e390\` FOREIGN KEY (\`ownerUsername\`) REFERENCES \`user\`(\`username\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + + const users = await services.userService.getAllUsers(); + for (const user of users) { + await services.settingsService.updateWidgets(user.username, DEFAULT_WIDGETS); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`dashboard_settings\` DROP FOREIGN KEY \`FK_44eca2a73ba5b456b2a9509e390\``, + ); + await queryRunner.query(`DROP TABLE \`dashboard_settings\``); + } +} diff --git a/backend/src/settings/controllers/SettingsController.ts b/backend/src/settings/controllers/SettingsController.ts new file mode 100644 index 00000000..e48bf3c5 --- /dev/null +++ b/backend/src/settings/controllers/SettingsController.ts @@ -0,0 +1,21 @@ +import { services } from '../../shared/services/services'; +import { AvailableWidgets, UserSettings } from '../models/Settings'; +import { SettingsController } from '../models/SettingsController'; + +const SETTINGS_CONTROLLER: SettingsController = { + getUserSettings: async (ctx) => { + const settings = await services.settingsService.getUserWidgets(ctx.state.user.username); + const userSettings: UserSettings = { widgets: settings.map((setting) => setting.widget as AvailableWidgets) }; + return { data: userSettings, status: 200 }; + }, + updateSettings: async (ctx) => { + const { widgets } = ctx.request.body; + const updatedSettings = await services.settingsService.updateWidgets(ctx.state.user.username, widgets); + const updatedUserSettings: UserSettings = { + widgets: updatedSettings.map((setting) => setting.widget), + }; + return { data: updatedUserSettings, status: 200 }; + }, +}; + +export default SETTINGS_CONTROLLER; diff --git a/backend/src/settings/models/Settings.ts b/backend/src/settings/models/Settings.ts new file mode 100644 index 00000000..9172a1ad --- /dev/null +++ b/backend/src/settings/models/Settings.ts @@ -0,0 +1,28 @@ +export const DEFAULT_WIDGETS: AvailableWidgets[] = [ + 'general-header', + 'quick-actions', + 'month-picker', + 'overview-header', + 'month-status', + 'category-data', + 'current-status', + 'daily-records', + 'historical-data-header', + 'latest-records', + 'this-year', +]; + +export type AvailableWidgets = + | 'general-header' + | 'quick-actions' + | 'month-picker' + | 'overview-header' + | 'month-status' + | 'category-data' + | 'current-status' + | 'daily-records' + | 'historical-data-header' + | 'latest-records' + | 'this-year'; + +export type UserSettings = { widgets: AvailableWidgets[] }; diff --git a/backend/src/settings/models/SettingsController.ts b/backend/src/settings/models/SettingsController.ts new file mode 100644 index 00000000..0e726eb0 --- /dev/null +++ b/backend/src/settings/models/SettingsController.ts @@ -0,0 +1,7 @@ +import { ControllerFunction } from '../../shared/models/BaseController'; +import { UserSettings } from './Settings'; + +export interface SettingsController { + getUserSettings: ControllerFunction; + updateSettings: ControllerFunction; +} diff --git a/backend/src/settings/services/SettingsService.ts b/backend/src/settings/services/SettingsService.ts new file mode 100644 index 00000000..bc89a59e --- /dev/null +++ b/backend/src/settings/services/SettingsService.ts @@ -0,0 +1,18 @@ +import { User } from '../../entity/User'; +import { repositories } from '../../shared/repositories/database'; +import { AvailableWidgets } from '../models/Settings'; + +export default class SettingsService { + getUserWidgets(username: User['username']) { + return repositories.settings().find({ where: { ownerUsername: username }, order: { order: 'ASC' } }); + } + async updateWidgets(username: User['username'], widgets: AvailableWidgets[]) { + const newWidgetOrder = widgets.map((widget, index) => ({ + widget, + order: index, + ownerUsername: username, + })); + await repositories.settings().delete({ ownerUsername: username }); + return repositories.settings().save(newWidgetOrder); + } +} diff --git a/backend/src/settings/services/settingsRouter.ts b/backend/src/settings/services/settingsRouter.ts new file mode 100644 index 00000000..c0c1ebb1 --- /dev/null +++ b/backend/src/settings/services/settingsRouter.ts @@ -0,0 +1,9 @@ +import Router from '@koa/router'; +import SETTINGS_CONTROLLER from '../controllers/SettingsController'; + +const router = new Router(); + +router.get('/', SETTINGS_CONTROLLER.getUserSettings); +router.put('/', SETTINGS_CONTROLLER.updateSettings); + +export default router.routes(); diff --git a/backend/src/shared/repositories/authenticationMapper.ts b/backend/src/shared/repositories/authenticationMapper.ts index dd0110eb..4bb1af81 100644 --- a/backend/src/shared/repositories/authenticationMapper.ts +++ b/backend/src/shared/repositories/authenticationMapper.ts @@ -1,5 +1,6 @@ import passport from 'koa-passport'; import { Strategy as LocalStrategy } from 'passport-local'; +import { DEFAULT_WIDGETS } from '../../settings/models/Settings'; import { DuplicatedUser } from '../../user/models/Errors'; import { BadRequest, InvalidCredentials, MissingProperty } from '../models/Errors'; import { LoginToken } from '../models/Login'; @@ -81,5 +82,6 @@ export const register = async (username: string, password: string, email: string const newUser = await repositories .users() .save({ username, password: encryptedPassword, private_key: privateKey, email }); + await services.settingsService.updateWidgets(newUser.username, DEFAULT_WIDGETS); return { username: newUser.username, email: newUser.email }; }; diff --git a/backend/src/shared/repositories/database.ts b/backend/src/shared/repositories/database.ts index dd2ee144..cffad0c5 100644 --- a/backend/src/shared/repositories/database.ts +++ b/backend/src/shared/repositories/database.ts @@ -4,6 +4,7 @@ import { User } from '../../entity/User'; import { CategoryRepository } from '../../category/repositories/CategoryRepository'; import { WalletRepository } from '../../wallet/repositories/WalletRepository'; import { RecurrentRecord } from '../../entity/RecurrentRecord'; +import { DashboardSettings } from '../../entity/DashboardSettings'; export const connection = createConnection(); @@ -13,4 +14,5 @@ export const repositories = { categories: () => getCustomRepository(CategoryRepository), users: () => getRepository(User), recurrentRecords: () => getRepository(RecurrentRecord), + settings: () => getRepository(DashboardSettings), }; diff --git a/backend/src/shared/services/services.ts b/backend/src/shared/services/services.ts index 2d9759dd..c602a989 100644 --- a/backend/src/shared/services/services.ts +++ b/backend/src/shared/services/services.ts @@ -1,6 +1,7 @@ import { CategoryService } from '../../category/services/CategoryService'; import { RecordService } from '../../record/services/RecordService'; import { RecurrentRecordService } from '../../record/services/RecurrentRecordService'; +import SettingsService from '../../settings/services/SettingsService'; import { StatisticsService } from '../../statistics/services/StatisticsService'; import UserService from '../../user/services/UserService'; import WalletService from '../../wallet/services/WalletService'; @@ -14,4 +15,5 @@ export const services = { userService: new UserService(), statisticsService: new StatisticsService(), authenticationService: new AuthenticationService(), + settingsService: new SettingsService(), }; diff --git a/backend/src/user/services/UserService.ts b/backend/src/user/services/UserService.ts index 66b0b726..6d4bde92 100644 --- a/backend/src/user/services/UserService.ts +++ b/backend/src/user/services/UserService.ts @@ -41,4 +41,8 @@ export default class UserService { repositories.users().save({ ...user, password: encryptedNewPassword }); return 'Successfully updated password'; } + + async getAllUsers() { + return repositories.users().find(); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a03d817..557fb967 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,8 @@ "next": "^12.1.0", "next-pwa": "^5.4.6", "react": "^17.0.2", + "react-dnd": "^15.1.1", + "react-dnd-html5-backend": "^15.1.2", "react-dom": "^17.0.2", "react-query": "^3.34.16", "recharts": "^2.1.9", @@ -2523,6 +2525,21 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "node_modules/@react-dnd/invariant": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.0.tgz", + "integrity": "sha512-keberJRIqPX15IK3SWS/iO1t/kGETiL1oczKrDitAaMnQ+kpHf81l3MrRmFjvfqcnApE+izEvwM6GsyoIcpsVA==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.0.tgz", + "integrity": "sha512-1ELWQdJB2UrCXTKK5cCD9uGLLIwECLIEdttKA255owdpchtXohIjZBTlFJszwYi2ZKe2Do+QvUzsGyGCMNwbdw==" + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3853,6 +3870,16 @@ "node": ">=8" } }, + "node_modules/dnd-core": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.1.tgz", + "integrity": "sha512-Mtj/Sltcx7stVXzeDg4g7roTe/AmzRuIf/FYOxX6F8gULbY54w066BlErBOzQfn9RIJ3gAYLGX7wvVvoBSq7ig==", + "dependencies": { + "@react-dnd/asap": "4.0.0", + "@react-dnd/invariant": "3.0.0", + "redux": "^4.1.1" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6278,6 +6305,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.1.tgz", + "integrity": "sha512-QLrHtPU08U4c5zop0ANeqrHXaQw2EWLMn8DQoN6/e4eSN/UbB84P49/80Qg0MEF29VLB5vikSoiFh9N8ASNmpQ==", + "dependencies": { + "@react-dnd/invariant": "3.0.0", + "@react-dnd/shallowequal": "3.0.0", + "dnd-core": "15.1.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.2.tgz", + "integrity": "sha512-mem9QbutUF+aA2YC1y47G3ECjnYV/sCYKSnu5Jd7cbg3fLMPAwbnTf/JayYdnCH5l3eg9akD9dQt+cD0UdF8QQ==", + "dependencies": { + "dnd-core": "15.1.1" + } + }, "node_modules/react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -6467,6 +6531,14 @@ "postcss-value-parser": "^3.3.0" } }, + "node_modules/redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -9521,6 +9593,21 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz", "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==" }, + "@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "@react-dnd/invariant": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-3.0.0.tgz", + "integrity": "sha512-keberJRIqPX15IK3SWS/iO1t/kGETiL1oczKrDitAaMnQ+kpHf81l3MrRmFjvfqcnApE+izEvwM6GsyoIcpsVA==" + }, + "@react-dnd/shallowequal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-3.0.0.tgz", + "integrity": "sha512-1ELWQdJB2UrCXTKK5cCD9uGLLIwECLIEdttKA255owdpchtXohIjZBTlFJszwYi2ZKe2Do+QvUzsGyGCMNwbdw==" + }, "@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -10581,6 +10668,16 @@ "path-type": "^4.0.0" } }, + "dnd-core": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.1.tgz", + "integrity": "sha512-Mtj/Sltcx7stVXzeDg4g7roTe/AmzRuIf/FYOxX6F8gULbY54w066BlErBOzQfn9RIJ3gAYLGX7wvVvoBSq7ig==", + "requires": { + "@react-dnd/asap": "4.0.0", + "@react-dnd/invariant": "3.0.0", + "redux": "^4.1.1" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -12412,6 +12509,26 @@ "object-assign": "^4.1.1" } }, + "react-dnd": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-15.1.1.tgz", + "integrity": "sha512-QLrHtPU08U4c5zop0ANeqrHXaQw2EWLMn8DQoN6/e4eSN/UbB84P49/80Qg0MEF29VLB5vikSoiFh9N8ASNmpQ==", + "requires": { + "@react-dnd/invariant": "3.0.0", + "@react-dnd/shallowequal": "3.0.0", + "dnd-core": "15.1.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "15.1.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-15.1.2.tgz", + "integrity": "sha512-mem9QbutUF+aA2YC1y47G3ECjnYV/sCYKSnu5Jd7cbg3fLMPAwbnTf/JayYdnCH5l3eg9akD9dQt+cD0UdF8QQ==", + "requires": { + "dnd-core": "15.1.1" + } + }, "react-dom": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", @@ -12552,6 +12669,14 @@ "postcss-value-parser": "^3.3.0" } }, + "redux": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz", + "integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 00ef12a6..2effb791 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ "next": "^12.1.0", "next-pwa": "^5.4.6", "react": "^17.0.2", + "react-dnd": "^15.1.1", + "react-dnd-html5-backend": "^15.1.2", "react-dom": "^17.0.2", "react-query": "^3.34.16", "recharts": "^2.1.9", diff --git a/frontend/pages/home/index.tsx b/frontend/pages/home/index.tsx index b515bdca..71cd6ca8 100644 --- a/frontend/pages/home/index.tsx +++ b/frontend/pages/home/index.tsx @@ -1,122 +1,156 @@ import { Grid } from '@mui/material'; import dayjs from 'dayjs'; import Head from 'next/head'; -import * as React from 'react'; +import { FunctionComponent, useState } from 'react'; import { CategoryDataWidget } from '../../src/dashboard/components/CategoryDataWidget'; import { CurrentStatusWidget } from '../../src/dashboard/components/CurrentStatusWidget'; import { DailyDataWidget } from '../../src/dashboard/components/DailyDataWidget'; -import { GeneralHeaderWidget } from '../../src/dashboard/components/GeneralHeaderWidget'; -import { HistoricalDataHeaderWidget } from '../../src/dashboard/components/HistoricalDataHeaderWidget'; import { LatestRecordsWidget } from '../../src/dashboard/components/LatestRecordsWidget'; +import { LoadingWidgets } from '../../src/dashboard/components/LoadingWidgets'; import { MonthPickerWidget } from '../../src/dashboard/components/MonthPickerWidget'; import { MonthStatusWidget } from '../../src/dashboard/components/MonthStatusWidget'; -import { OverviewHeaderWidget } from '../../src/dashboard/components/OverviewHeaderWidget'; import { QuickActionsWidget } from '../../src/dashboard/components/QuickActionsWidget'; import { ThisYearWidget } from '../../src/dashboard/components/ThisYearWidget'; +import { WidgetProps } from '../../src/dashboard/components/Widget'; +import { + HeaderWidgetProps, + WidgetHeader, +} from '../../src/dashboard/components/WidgetHeader'; +import { + getHeaderWidgetOfWidget, + headerWidgetMapping, + widgetLabels, +} from '../../src/dashboard/constants/widgets'; import { AvailableWidgets } from '../../src/dashboard/models/AvailableWidgets'; +import { + useUpdateUserSettingsMutation, + useUserSettingsQuery, +} from '../../src/settings/hooks/useSettingsQuery'; import { useWalletsQuery } from '../../src/wallets/hooks/walletsQueries'; -const defaultWidgetIdsOrder: AvailableWidgets[] = [ - 'general-header', - 'quick-actions', - 'month-picker', - 'overview-header', - 'month-status', - 'category-data', - 'current-status', - 'daily-records', - 'historical-data-header', - 'latest-records', - 'this-year', -]; +const DashboardPage: FunctionComponent = (props) => { + const [selectedWallet, setSelectedWallet] = useState('all'); -const DashboardPage: React.FunctionComponent = (props) => { - const [selectedWallet, setSelectedWallet] = React.useState('all'); - const [currentDate, setCurrentDate] = React.useState(dayjs()); + const [currentDate, setCurrentDate] = useState(dayjs()); const { data: wallets } = useWalletsQuery(); + const { data: dashboardWidgetsOrder, isLoading } = useUserSettingsQuery(); + const updateUserSettings = useUpdateUserSettingsMutation(); - const [dashboardWidgetsOrder, setDashboardWidgetsOrder] = React.useState< - AvailableWidgets[] - >(defaultWidgetIdsOrder); + const getMissingWidgetsForHeader = ( + headerWidget: AvailableWidgets + ): AvailableWidgets[] => { + const headerWidgets = headerWidgetMapping[headerWidget]; + if (!headerWidgets) { + return []; + } + return headerWidgets.filter( + (widget) => !dashboardWidgetsOrder.widgets.includes(widget) + ); + }; - const handleWidgetMove = ( - target: string, - event: React.DragEvent + const handleWidgetAdd = async (widget: AvailableWidgets) => { + const headerOfWidget = getHeaderWidgetOfWidget(widget); + const indexOfHeader = dashboardWidgetsOrder.widgets.indexOf(headerOfWidget); + if (indexOfHeader >= 0) { + const newWidgets = [ + ...dashboardWidgetsOrder.widgets.slice(0, indexOfHeader + 1), + widget, + ...dashboardWidgetsOrder.widgets.slice(indexOfHeader + 1), + ]; + await updateUserSettings.mutateAsync({ + widgets: newWidgets, + }); + } + }; + + const handleWidgetMove = async ( + sourceWidget: AvailableWidgets, + targetWidget: AvailableWidgets ) => { - const sourceWidget = event.dataTransfer.types[0]; - const targetWidget = target; - const newOrders = [...dashboardWidgetsOrder]; - const sourceIndex = newOrders.indexOf(sourceWidget as AvailableWidgets); - const targetIndex = newOrders.indexOf(targetWidget as AvailableWidgets); - newOrders[sourceIndex] = targetWidget as AvailableWidgets; - newOrders[targetIndex] = sourceWidget as AvailableWidgets; - setDashboardWidgetsOrder(newOrders); + const newOrders = [...dashboardWidgetsOrder.widgets]; + const sourceIndex = newOrders.indexOf(sourceWidget); + const targetIndex = newOrders.indexOf(targetWidget); + newOrders[sourceIndex] = targetWidget; + newOrders[targetIndex] = sourceWidget; + await updateUserSettings.mutateAsync({ widgets: newOrders }); + }; + + const handleWidgetRemove = async (widget: AvailableWidgets) => { + const newOrders = [...dashboardWidgetsOrder.widgets]; + const index = newOrders.indexOf(widget); + newOrders.splice(index, 1); + await updateUserSettings.mutateAsync({ widgets: newOrders }); }; const getWidgetForWidgetId = (widgetId: AvailableWidgets) => { + const editableWidgetProps: WidgetProps = { + title: widgetLabels[widgetId], + onWidgetDrop: handleWidgetMove, + onWidgetRemove: handleWidgetRemove, + widgetId, + }; + const headerWidgetProps: HeaderWidgetProps = { + title: widgetLabels[widgetId], + widgetAdded: handleWidgetAdd, + addableWidgets: getMissingWidgetsForHeader(widgetId), + }; switch (widgetId) { - case 'category-data': + case AvailableWidgets.CATEGORY_DATA: return ( ); - case 'current-status': + case AvailableWidgets.CURRENT_STATUS: return ( ); - case 'daily-records': + case AvailableWidgets.DAILY_RECORDS: return ( ); - case 'general-header': - return ; - case 'historical-data-header': - return ; - case 'latest-records': - return ( - - ); - case 'month-picker': + case AvailableWidgets.GENERAL_HEADER: + return ; + case AvailableWidgets.HISTORICAL_DATA_HEADER: + return ; + case AvailableWidgets.LATEST_RECORDS: + return ; + case AvailableWidgets.MONTH_PICKER: return ( ); - case 'month-status': + case AvailableWidgets.MONTH_STATUS: return ( ); - case 'overview-header': - return ; - case 'quick-actions': - return ( - - ); - case 'this-year': - return ( - - ); + case AvailableWidgets.OVERVIEW_HEADER: + return ; + case AvailableWidgets.QUICK_ACTIONS: + return ; + case AvailableWidgets.THIS_YEAR: + return ; } }; @@ -136,8 +170,14 @@ const DashboardPage: React.FunctionComponent = (props) => { /> - {dashboardWidgetsOrder.map((widgetId) => - getWidgetForWidgetId(widgetId) + {isLoading ? ( + + ) : ( + <> + {dashboardWidgetsOrder.widgets.map((widgetId) => + getWidgetForWidgetId(widgetId) + )} + )} diff --git a/frontend/src/dashboard/components/CategoryDataWidget.tsx b/frontend/src/dashboard/components/CategoryDataWidget.tsx index a1363d2a..7ae4912c 100644 --- a/frontend/src/dashboard/components/CategoryDataWidget.tsx +++ b/frontend/src/dashboard/components/CategoryDataWidget.tsx @@ -2,20 +2,19 @@ import { Dayjs } from 'dayjs'; import * as React from 'react'; import MonthlyCategory from './MonthlyCategory'; import Widget from './Widget'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import { DateableWidget } from '../models/DateableWidget'; export const CategoryDataWidget: React.FC< - MovableWidgetProps & DateableWidget + EditableWidgetProps & DateableWidget > = (props) => { const { date, ...rest } = props; return ( diff --git a/frontend/src/dashboard/components/CurrentStatusWidget.tsx b/frontend/src/dashboard/components/CurrentStatusWidget.tsx index f1613ef2..2c9ffc44 100644 --- a/frontend/src/dashboard/components/CurrentStatusWidget.tsx +++ b/frontend/src/dashboard/components/CurrentStatusWidget.tsx @@ -1,20 +1,14 @@ import { Wallet } from '../../wallets/models/Wallet'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import CurrentStatus from './CurrentStatus'; import Widget from './Widget'; export const CurrentStatusWidget: React.FC< - MovableWidgetProps & { wallets: Wallet[] } + EditableWidgetProps & { wallets: Wallet[] } > = (props) => { const { wallets, ...rest } = props; return ( - + {wallets && } ); diff --git a/frontend/src/dashboard/components/DailyDataWidget.tsx b/frontend/src/dashboard/components/DailyDataWidget.tsx index d45843c9..f815481f 100644 --- a/frontend/src/dashboard/components/DailyDataWidget.tsx +++ b/frontend/src/dashboard/components/DailyDataWidget.tsx @@ -1,12 +1,12 @@ import { WalletField } from '../../records/components/WalletField'; import { Wallet } from '../../wallets/models/Wallet'; import { DateableWidget } from '../models/DateableWidget'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import ThisMonth from './ThisMonth'; import Widget from './Widget'; export const DailyDataWidget: React.FC< - MovableWidgetProps & + EditableWidgetProps & DateableWidget & { selectedWallet: string; wallets: Wallet[]; @@ -16,6 +16,7 @@ export const DailyDataWidget: React.FC< const { date, selectedWallet, wallets, setSelectedWallet, ...rest } = props; return ( , ]} - widgetId="daily-records" - {...rest} > {wallets && ( <> diff --git a/frontend/src/dashboard/components/GeneralHeaderWidget.tsx b/frontend/src/dashboard/components/GeneralHeaderWidget.tsx deleted file mode 100644 index 8451795f..00000000 --- a/frontend/src/dashboard/components/GeneralHeaderWidget.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { OptionalWidgetProps } from '../models/OptionalWidgetProps'; -import { WidgetHeader } from './WidgetHeader'; - -export const GeneralHeaderWidget: React.FC = () => ( - -); diff --git a/frontend/src/dashboard/components/HistoricalDataHeaderWidget.tsx b/frontend/src/dashboard/components/HistoricalDataHeaderWidget.tsx deleted file mode 100644 index 14d1ad3d..00000000 --- a/frontend/src/dashboard/components/HistoricalDataHeaderWidget.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { OptionalWidgetProps } from '../models/OptionalWidgetProps'; -import { WidgetHeader } from './WidgetHeader'; - -export const HistoricalDataHeaderWidget: React.FC = ( - props -) => ( - -); diff --git a/frontend/src/dashboard/components/LatestRecordsWidget.tsx b/frontend/src/dashboard/components/LatestRecordsWidget.tsx index 3abd5f20..ee74367b 100644 --- a/frontend/src/dashboard/components/LatestRecordsWidget.tsx +++ b/frontend/src/dashboard/components/LatestRecordsWidget.tsx @@ -1,15 +1,9 @@ import LatestRecords from './LatestRecords'; import Widget from './Widget'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; -export const LatestRecordsWidget: React.FC = (props) => ( - +export const LatestRecordsWidget: React.FC = (props) => ( + ); diff --git a/frontend/src/dashboard/components/LoadingWidget.tsx b/frontend/src/dashboard/components/LoadingWidget.tsx new file mode 100644 index 00000000..b9a94885 --- /dev/null +++ b/frontend/src/dashboard/components/LoadingWidget.tsx @@ -0,0 +1,45 @@ +import { Box, Grid, GridProps, Paper, Typography } from '@mui/material'; +import makeStyles from '@mui/styles/makeStyles'; +import { FunctionComponent } from 'react'; + +export interface WidgetProps { + title?: string | JSX.Element; + xs?: GridProps['xs']; + lg?: GridProps['lg']; + md?: GridProps['md']; + sm?: GridProps['sm']; + xl?: GridProps['xl']; +} + +const widgetStyle = makeStyles((theme) => ({ + widget: { + padding: theme.spacing(2), + }, +})); + +const LoadingWidget: FunctionComponent = (props) => { + const { children, title, lg, md, sm, xl, xs } = props; + const classes = widgetStyle(); + return ( + + + {title && ( + + {title && ( + + {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + + )} + + )} + {children} + + + ); +}; + +export default LoadingWidget; diff --git a/frontend/src/dashboard/components/LoadingWidgets.tsx b/frontend/src/dashboard/components/LoadingWidgets.tsx new file mode 100644 index 00000000..3f068cee --- /dev/null +++ b/frontend/src/dashboard/components/LoadingWidgets.tsx @@ -0,0 +1,72 @@ +import { Skeleton } from '@mui/material'; +import { FunctionComponent } from 'react'; +import LoadingWidget from './LoadingWidget'; + +export const LoadingWidgets: FunctionComponent = () => ( + <> + + + + } + > + + + } + > + + + + + + } + > + + + } + > + + + +); diff --git a/frontend/src/dashboard/components/MonthPickerWidget.tsx b/frontend/src/dashboard/components/MonthPickerWidget.tsx index 27a8c4ab..378e08ec 100644 --- a/frontend/src/dashboard/components/MonthPickerWidget.tsx +++ b/frontend/src/dashboard/components/MonthPickerWidget.tsx @@ -1,13 +1,13 @@ -import dayjs, { Dayjs } from 'dayjs'; import DatePicker from '@mui/lab/DatePicker'; +import { TextField } from '@mui/material'; +import { Dayjs } from 'dayjs'; +import { FunctionComponent, useState } from 'react'; import { DateableWidget } from '../models/DateableWidget'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import Widget from './Widget'; -import { TextField } from '@mui/material'; -import { useState } from 'react'; -export const MonthPickerWidget: React.FC< - MovableWidgetProps & +export const MonthPickerWidget: FunctionComponent< + EditableWidgetProps & DateableWidget & { setCurrentDate: (date: Dayjs) => void; } @@ -15,7 +15,7 @@ export const MonthPickerWidget: React.FC< const { date, setCurrentDate, ...rest } = props; const [selectedDate, setSelectedDate] = useState(date); return ( - + } views={['year', 'month']} diff --git a/frontend/src/dashboard/components/MonthStatusWidget.tsx b/frontend/src/dashboard/components/MonthStatusWidget.tsx index 859f4480..29d54f8d 100644 --- a/frontend/src/dashboard/components/MonthStatusWidget.tsx +++ b/frontend/src/dashboard/components/MonthStatusWidget.tsx @@ -1,16 +1,15 @@ import { DateableWidget } from '../models/DateableWidget'; -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import MonthStatus from './MonthStatus'; import Widget from './Widget'; export const MonthStatusWidget: React.FC< - MovableWidgetProps & DateableWidget + EditableWidgetProps & DateableWidget > = (props) => { const { date, ...rest } = props; return ( = (props) => ( - -); diff --git a/frontend/src/dashboard/components/QuickActionsWidget.tsx b/frontend/src/dashboard/components/QuickActionsWidget.tsx index cb7e067f..4d827322 100644 --- a/frontend/src/dashboard/components/QuickActionsWidget.tsx +++ b/frontend/src/dashboard/components/QuickActionsWidget.tsx @@ -1,9 +1,9 @@ -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import { QuickActions } from './QuickActions'; import Widget from './Widget'; -export const QuickActionsWidget: React.FC = (props) => ( - +export const QuickActionsWidget: React.FC = (props) => ( + ); diff --git a/frontend/src/dashboard/components/ThisYearWidget.tsx b/frontend/src/dashboard/components/ThisYearWidget.tsx index 2595f960..af1145c6 100644 --- a/frontend/src/dashboard/components/ThisYearWidget.tsx +++ b/frontend/src/dashboard/components/ThisYearWidget.tsx @@ -1,9 +1,9 @@ -import { MovableWidgetProps } from '../models/MovableWidgetProps'; +import { EditableWidgetProps } from '../models/EditableWidgetProps'; import ThisYear from './ThisYear'; import Widget from './Widget'; -export const ThisYearWidget: React.FC = (props) => ( - +export const ThisYearWidget: React.FC = (props) => ( + ); diff --git a/frontend/src/dashboard/components/Widget.tsx b/frontend/src/dashboard/components/Widget.tsx index a3072259..736f3db9 100644 --- a/frontend/src/dashboard/components/Widget.tsx +++ b/frontend/src/dashboard/components/Widget.tsx @@ -1,7 +1,22 @@ -import { Box, Collapse, Grid, GridProps, IconButton, Paper, Typography } from '@mui/material'; -import makeStyles from '@mui/styles/makeStyles'; -import { ExpandLess, ExpandMore } from '@mui/icons-material'; -import * as React from 'react'; +import { + Close, + DragIndicator, + ExpandLess, + ExpandMore, +} from '@mui/icons-material'; +import { + Box, + Collapse, + Grid, + GridProps, + IconButton, + Paper, + Typography, + useMediaQuery, +} from '@mui/material'; +import { styled } from '@mui/styles'; +import { FunctionComponent, useState } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; import { AvailableWidgets } from '../models/AvailableWidgets'; export interface WidgetProps { @@ -14,22 +29,29 @@ export interface WidgetProps { xl?: GridProps['xl']; disableClosable?: boolean; onWidgetDrop: ( - target: string, - event: React.DragEvent + sourceWidget: AvailableWidgets, + targetWidget: AvailableWidgets ) => void; widgetId: AvailableWidgets; + onWidgetRemove: (widget: AvailableWidgets) => void; } -const widgetStyle = makeStyles((theme) => ({ - widget: { - padding: theme.spacing(2), - }, - dropTarget: { - border: '2px dashed #ccc', - }, +const DragIcon = styled(Grid)(({ theme }) => ({ + cursor: 'move', + padding: theme.spacing(1), + height: theme.spacing(5), + width: theme.spacing(5), })); -const Widget: React.FunctionComponent = (props) => { +type WidgetDragObject = { + widgetId: AvailableWidgets; +}; + +type WidgetDropCollectedProps = { + isOver: boolean; +}; + +const Widget: FunctionComponent = (props) => { const { children, title, @@ -41,47 +63,54 @@ const Widget: React.FunctionComponent = (props) => { xs, disableClosable, onWidgetDrop, + onWidgetRemove, widgetId, } = props; - const classes = widgetStyle(); - const [open, setOpen] = React.useState(!disableClosable); - const [dropTarget, setDropTarget] = React.useState(false); + + const [open, setOpen] = useState(!disableClosable); + const isDesktop = useMediaQuery('(pointer: fine)'); + const [, drag, preview] = useDrag( + () => ({ + type: 'widget', + item: { + widgetId, + }, + options: { + dropEffect: 'move', + }, + }), + [widgetId] + ); + + const [{ isOver }, drop] = useDrop< + WidgetDragObject, + unknown, + WidgetDropCollectedProps + >( + () => ({ + accept: 'widget', + canDrop: (item) => item.widgetId !== widgetId, + drop: (item) => { + onWidgetDrop(widgetId, item.widgetId); + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver() && !!monitor.canDrop(), + }), + }), + [widgetId, onWidgetDrop] + ); + return ( - { - event.dataTransfer.setData(widgetId, ''); - event.dataTransfer.effectAllowed = 'move'; - }} - onDragOver={(event) => { - event.preventDefault(); - const sourceWidgetKey = event.dataTransfer.types[0]; - const targetWidgetKey = widgetId; - if (sourceWidgetKey === targetWidgetKey) { - event.dataTransfer.dropEffect = 'none'; - setDropTarget(false); - } else { - event.dataTransfer.dropEffect = 'move'; - setDropTarget(true); - } - }} - onDragLeave={() => { - setDropTarget(false); - }} - onDrop={(event) => { - setDropTarget(false); - onWidgetDrop(widgetId, event); - }} - item - xs={xs || 12} - lg={lg} - md={md} - xl={xl} - sm={sm} - > + ({ + ...(isOver && { + border: '2px dashed #ccc', + }), + padding: theme.spacing(2), + })} + ref={drop} variant="outlined" - className={`${classes.widget} ${dropTarget && classes.dropTarget}`} > {(title || actions || !disableClosable) && ( @@ -90,23 +119,37 @@ const Widget: React.FunctionComponent = (props) => { {{title}} )} - {(!disableClosable || actions) && ( - - {open && - actions?.map((action, idx) => ( - - {action} - - ))} - {!disableClosable && ( - - setOpen(!open)} size="large"> - {open ? : } - + + {open && + actions?.map((action, idx) => ( + + {action} - )} + ))} + {isDesktop && ( + + + + )} + + onWidgetRemove(widgetId)}> + + - )} + {!disableClosable && ( + + setOpen(!open)}> + {open ? : } + + + )} + )} diff --git a/frontend/src/dashboard/components/WidgetHeader.tsx b/frontend/src/dashboard/components/WidgetHeader.tsx index b93cbefd..ef7b3590 100644 --- a/frontend/src/dashboard/components/WidgetHeader.tsx +++ b/frontend/src/dashboard/components/WidgetHeader.tsx @@ -1,18 +1,80 @@ -import { AppBar, Grid, Toolbar, Typography } from '@mui/material'; -import * as React from 'react'; +import { AddBox } from '@mui/icons-material'; +import { + AppBar, + Grid, + IconButton, + Menu, + MenuItem, + Toolbar, + Tooltip, + Typography, +} from '@mui/material'; +import { + FunctionComponent, + MouseEvent as ReactMouseEvent, + useState, + useEffect, +} from 'react'; +import { widgetLabels } from '../constants/widgets'; +import { AvailableWidgets } from '../models/AvailableWidgets'; +import { WidgetProps } from './Widget'; -type WidgetHeaderProps = { - title: string; - widgetId: string; +export type HeaderWidgetProps = Partial & { + addableWidgets: AvailableWidgets[]; + widgetAdded: (widget: AvailableWidgets) => void; }; -export const WidgetHeader: React.FC = (props) => { - const { title } = props; +export const WidgetHeader: FunctionComponent = (props) => { + const { title, widgetAdded, addableWidgets } = props; + const [menuAnchor, setMenuAnchor] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + + const openMenu = (event: ReactMouseEvent) => { + setMenuOpen(true); + setMenuAnchor(event.currentTarget); + }; + + const closeMenu = () => { + setMenuOpen(false); + setMenuAnchor(null); + }; + + useEffect(() => { + if (!menuOpen && !!menuAnchor) { + return; + } + addableWidgets?.length === 0 && closeMenu(); + }, [menuOpen, menuAnchor, addableWidgets]); + return ( {title} + + + + + + + + {addableWidgets?.map((widgetId) => ( + widgetAdded(widgetId)}> + {widgetLabels[widgetId]} + + ))} + + diff --git a/frontend/src/dashboard/constants/widgets.ts b/frontend/src/dashboard/constants/widgets.ts new file mode 100644 index 00000000..5dde454d --- /dev/null +++ b/frontend/src/dashboard/constants/widgets.ts @@ -0,0 +1,41 @@ +import { AvailableWidgets } from '../models/AvailableWidgets'; + +export const widgetLabels: { [key in AvailableWidgets]: string } = { + [AvailableWidgets.GENERAL_HEADER]: 'General', + [AvailableWidgets.QUICK_ACTIONS]: 'Quick Actions', + [AvailableWidgets.MONTH_PICKER]: 'Month Picker', + [AvailableWidgets.OVERVIEW_HEADER]: 'Overview', + [AvailableWidgets.MONTH_STATUS]: 'Month Status', + [AvailableWidgets.HISTORICAL_DATA_HEADER]: 'Historical Data', + [AvailableWidgets.LATEST_RECORDS]: 'Latest Records', + [AvailableWidgets.THIS_YEAR]: 'This Year', + [AvailableWidgets.CATEGORY_DATA]: 'Category Data', + [AvailableWidgets.CURRENT_STATUS]: 'Current Status', + [AvailableWidgets.DAILY_RECORDS]: 'Daily Records', +}; + +export const headerWidgetMapping: { + [key in AvailableWidgets]?: AvailableWidgets[]; +} = { + [AvailableWidgets.GENERAL_HEADER]: [ + AvailableWidgets.QUICK_ACTIONS, + AvailableWidgets.MONTH_PICKER, + ], + [AvailableWidgets.OVERVIEW_HEADER]: [ + AvailableWidgets.MONTH_STATUS, + AvailableWidgets.CATEGORY_DATA, + AvailableWidgets.CURRENT_STATUS, + AvailableWidgets.DAILY_RECORDS, + ], + [AvailableWidgets.HISTORICAL_DATA_HEADER]: [ + AvailableWidgets.LATEST_RECORDS, + AvailableWidgets.THIS_YEAR, + ], +}; + +export const getHeaderWidgetOfWidget = (widget: AvailableWidgets) => { + const headerWidget = Object.keys(headerWidgetMapping).find((key) => + headerWidgetMapping[key].includes(widget) + ); + return headerWidget as AvailableWidgets; +}; diff --git a/frontend/src/dashboard/models/AvailableWidgets.tsx b/frontend/src/dashboard/models/AvailableWidgets.tsx index 1de01fb8..35afc7b0 100644 --- a/frontend/src/dashboard/models/AvailableWidgets.tsx +++ b/frontend/src/dashboard/models/AvailableWidgets.tsx @@ -1,13 +1,13 @@ -export type AvailableWidgets = - | 'general-header' - | 'quick-actions' - | 'month-picker' - | 'overview-header' - | 'month-status' - | 'category-data' - | 'current-status' - | 'daily-records' - | 'historical-data-header' - | 'daily-records' - | 'latest-records' - | 'this-year'; +export enum AvailableWidgets { + GENERAL_HEADER = 'general-header', + QUICK_ACTIONS = 'quick-actions', + MONTH_PICKER = 'month-picker', + OVERVIEW_HEADER = 'overview-header', + MONTH_STATUS = 'month-status', + HISTORICAL_DATA_HEADER = 'historical-data-header', + LATEST_RECORDS = 'latest-records', + THIS_YEAR = 'this-year', + CATEGORY_DATA = 'category-data', + CURRENT_STATUS = 'current-status', + DAILY_RECORDS = 'daily-records', +} diff --git a/frontend/src/dashboard/models/EditableWidgetProps.tsx b/frontend/src/dashboard/models/EditableWidgetProps.tsx new file mode 100644 index 00000000..4607e838 --- /dev/null +++ b/frontend/src/dashboard/models/EditableWidgetProps.tsx @@ -0,0 +1,11 @@ +import { WidgetProps } from '../components/Widget'; + +export type MovableWidgetProps = WidgetProps & { + onWidgetDrop: WidgetProps['onWidgetDrop']; +}; + +export type RemoveableWidgetProps = WidgetProps & { + onWidgetRemove: WidgetProps['onWidgetRemove']; +}; + +export type EditableWidgetProps = MovableWidgetProps & RemoveableWidgetProps; diff --git a/frontend/src/dashboard/models/MovableWidgetProps.tsx b/frontend/src/dashboard/models/MovableWidgetProps.tsx deleted file mode 100644 index 967d6277..00000000 --- a/frontend/src/dashboard/models/MovableWidgetProps.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { WidgetProps } from '../components/Widget'; - -export type MovableWidgetProps = Partial & { - onWidgetDrop: WidgetProps['onWidgetDrop']; -}; diff --git a/frontend/src/dashboard/models/OptionalWidgetProps.tsx b/frontend/src/dashboard/models/OptionalWidgetProps.tsx deleted file mode 100644 index 669356e0..00000000 --- a/frontend/src/dashboard/models/OptionalWidgetProps.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { WidgetProps } from '../components/Widget'; - -export type OptionalWidgetProps = Partial; diff --git a/frontend/src/settings/hooks/useSettingsQuery.ts b/frontend/src/settings/hooks/useSettingsQuery.ts new file mode 100644 index 00000000..afb3625c --- /dev/null +++ b/frontend/src/settings/hooks/useSettingsQuery.ts @@ -0,0 +1,21 @@ +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { UserSettings } from '../models/UserSettings'; +import { SettingsApiService } from '../services/SettingsApi'; + +const settingsApi = new SettingsApiService(); + +export const useUserSettingsQuery = () => { + return useQuery( + ['getUserSettings'], + () => settingsApi.getUserSettings(), + { staleTime: 15000 } + ); +}; + +export const useUpdateUserSettingsMutation = () => { + const queryClient = useQueryClient(); + return useMutation( + (settings: UserSettings) => settingsApi.updateSettings(settings), + { onSuccess: () => queryClient.invalidateQueries('getUserSettings') } + ); +}; diff --git a/frontend/src/settings/models/UserSettings.ts b/frontend/src/settings/models/UserSettings.ts new file mode 100644 index 00000000..1f9e8f60 --- /dev/null +++ b/frontend/src/settings/models/UserSettings.ts @@ -0,0 +1,5 @@ +import { AvailableWidgets } from '../../dashboard/models/AvailableWidgets'; + +export interface UserSettings { + widgets: AvailableWidgets[]; +} diff --git a/frontend/src/settings/services/SettingsApi.ts b/frontend/src/settings/services/SettingsApi.ts index 0f7a469d..c3419c44 100644 --- a/frontend/src/settings/services/SettingsApi.ts +++ b/frontend/src/settings/services/SettingsApi.ts @@ -1,5 +1,6 @@ import { API_ROUTES } from '../../shared/constants/ApiRoutes'; import { BASE_API } from '../../shared/models/Api'; +import { UserSettings } from '../models/UserSettings'; export interface SettingsApi { changePassword(oldPassword: string, newPassword: string): Promise; @@ -12,4 +13,10 @@ export class SettingsApiService implements SettingsApi { newPassword, }); } + getUserSettings(): Promise { + return BASE_API.get(API_ROUTES.SETTINGS); + } + updateSettings(settings: UserSettings): Promise { + return BASE_API.put(API_ROUTES.SETTINGS, settings); + } } diff --git a/frontend/src/shared/components/Providers.tsx b/frontend/src/shared/components/Providers.tsx index 03924bc5..f1dc560b 100644 --- a/frontend/src/shared/components/Providers.tsx +++ b/frontend/src/shared/components/Providers.tsx @@ -13,6 +13,8 @@ import { AuthenticationProvider } from '../../authentication/components/Authenti import { AccTheme } from '../globals/styles/AccTheme'; import DialogsProvider from './DialogsProvider'; import NotificationBar from './NotificationBar'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; declare module '@mui/styles/defaultTheme' { interface DefaultTheme extends Theme {} @@ -29,12 +31,14 @@ const Providers: FunctionComponent = (props) => { - - - - {props.children} - - + + + + + {props.children} + + + diff --git a/frontend/src/shared/constants/ApiRoutes.ts b/frontend/src/shared/constants/ApiRoutes.ts index 2a4ab6ff..cba7d65b 100644 --- a/frontend/src/shared/constants/ApiRoutes.ts +++ b/frontend/src/shared/constants/ApiRoutes.ts @@ -19,4 +19,5 @@ export class API_ROUTES { static readonly STATISTICS = `${API_ROUTES.BASE_ROUTE}/statistics`; static readonly STATISTICS_CATEGORIES = `${API_ROUTES.STATISTICS}/categories`; static readonly STATISTICS_STATUS = `${API_ROUTES.STATISTICS}/month-status`; + static readonly SETTINGS = `${API_ROUTES.BASE_ROUTE}/settings`; } diff --git a/frontend/src/shared/globals/styles/AccTheme.ts b/frontend/src/shared/globals/styles/AccTheme.ts index 71740123..ade3d039 100644 --- a/frontend/src/shared/globals/styles/AccTheme.ts +++ b/frontend/src/shared/globals/styles/AccTheme.ts @@ -1,4 +1,4 @@ -import { createTheme } from '@mui/material'; +import { alpha, createTheme } from '@mui/material'; export const AccTheme = createTheme({ palette: { @@ -67,5 +67,17 @@ export const AccTheme = createTheme({ }), }, }, + MuiIconButton: { + styleOverrides: { + root: ({ theme, ownerState }) => ({ + '&.Mui-disabled': { + color: + ownerState.color === 'primary' + ? alpha(theme.palette.primary.main, 0.5) + : alpha(theme.palette.secondary.main, 0.5), + }, + }), + }, + }, }, });