diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java index 60f5208d..b1bb4afa 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/WidgetType.java @@ -118,7 +118,16 @@ public enum WidgetType { CATEGORY_BOXPLOT, MONTHLY_BOXPLOT, YEARLY_SLOPE, - CATEGORY_SANKEY)); + CATEGORY_SANKEY, + EXPENSE_FREQUENCY_STATCARD, + INCOME_FREQUENCY_STATCARD, + NO_SPEND_DAYS_STATCARD, + TOP_EXPENSE_CATEGORY_STATCARD, + TOP_INCOME_CATEGORY_STATCARD, + TOP_EXPENSE_TRANSACTION_STATCARD, + TOP_INCOME_TRANSACTION_STATCARD, + BURN_RATE_STATCARD, + SAVINGS_RATE_STATCARD)); public boolean isStatCard() { return STAT_CARD_TYPES.contains(this); diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java index 4e365593..21b563bc 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/ChartWidgetDTO.java @@ -1,7 +1,9 @@ package com.exence.finance.modules.statistics.dto.response; import com.exence.finance.modules.statistics.dto.Timeframe; +import com.exence.finance.modules.statistics.dto.WidgetSetting; import com.exence.finance.modules.statistics.dto.WidgetType; +import java.util.Map; public record ChartWidgetDTO( Long id, @@ -12,4 +14,5 @@ public record ChartWidgetDTO( Integer x, Integer y, Integer cols, - Integer rows) {} + Integer rows, + Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java index a9c442b5..0b7c9be2 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/dto/response/WidgetDataResponse.java @@ -1,6 +1,9 @@ package com.exence.finance.modules.statistics.dto.response; +import com.exence.finance.modules.statistics.dto.WidgetSetting; import com.exence.finance.modules.statistics.dto.WidgetType; import com.exence.finance.modules.statistics.dto.payload.WidgetDataPayload; +import java.util.Map; -public record WidgetDataResponse(Long widgetId, WidgetType type, WidgetDataPayload payload) {} +public record WidgetDataResponse( + Long widgetId, WidgetType type, WidgetDataPayload payload, Map settings) {} diff --git a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java index 8771d608..75a42a9a 100644 --- a/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java +++ b/backend/exence/src/main/java/com/exence/finance/modules/statistics/service/impl/WidgetServiceImpl.java @@ -168,7 +168,7 @@ public WidgetDataResponse getWidgetData(Long widgetId, Timeframe timeframe) { } WidgetDataPayload payload = provider.getData(request); - return new WidgetDataResponse(widget.getId(), widget.getType(), payload); + return new WidgetDataResponse(widget.getId(), widget.getType(), payload, widget.getSettings()); } @Override diff --git a/frontend/Exence/src/app/data-model/modules/statistics/WidgetDataReponse.ts b/frontend/Exence/src/app/data-model/modules/statistics/WidgetDataReponse.ts index 80d222d9..ea1a125b 100644 --- a/frontend/Exence/src/app/data-model/modules/statistics/WidgetDataReponse.ts +++ b/frontend/Exence/src/app/data-model/modules/statistics/WidgetDataReponse.ts @@ -1,8 +1,10 @@ import { WidgetType } from './widget-config.model'; import { WidgetDataPayload } from './WidgetDataPayload'; +import { WidgetSetting } from './WidgetSetting'; export interface WidgetDataResponse { widgetId: number; type: WidgetType; payload: T; + settings?: Record; } diff --git a/frontend/Exence/src/app/data-model/modules/statistics/widget-config.model.ts b/frontend/Exence/src/app/data-model/modules/statistics/widget-config.model.ts index 6da07cb7..bbdc6667 100644 --- a/frontend/Exence/src/app/data-model/modules/statistics/widget-config.model.ts +++ b/frontend/Exence/src/app/data-model/modules/statistics/widget-config.model.ts @@ -500,6 +500,15 @@ export const WIDGET_CATEGORY_TYPES: Partial> [WidgetType.MONTHLY_BOXPLOT]: [CategoryType.INCOME, CategoryType.EXPENSE], [WidgetType.YEARLY_SLOPE]: [CategoryType.INCOME, CategoryType.EXPENSE], [WidgetType.CATEGORY_SANKEY]: [CategoryType.INCOME, CategoryType.EXPENSE], + [WidgetType.EXPENSE_FREQUENCY_STATCARD]: [CategoryType.EXPENSE], + [WidgetType.INCOME_FREQUENCY_STATCARD]: [CategoryType.INCOME], + [WidgetType.NO_SPEND_DAYS_STATCARD]: [CategoryType.EXPENSE], + [WidgetType.TOP_EXPENSE_CATEGORY_STATCARD]: [CategoryType.EXPENSE], + [WidgetType.TOP_INCOME_CATEGORY_STATCARD]: [CategoryType.INCOME], + [WidgetType.TOP_EXPENSE_TRANSACTION_STATCARD]: [CategoryType.EXPENSE], + [WidgetType.TOP_INCOME_TRANSACTION_STATCARD]: [CategoryType.INCOME], + [WidgetType.BURN_RATE_STATCARD]: [CategoryType.EXPENSE], + [WidgetType.SAVINGS_RATE_STATCARD]: [CategoryType.INCOME], }; export const CATEGORY_FILTERABLE_WIDGET_TYPES: WidgetType[] = Object.keys(WIDGET_CATEGORY_TYPES) as WidgetType[]; diff --git a/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.html b/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.html index 1e9115f0..5bec8e09 100644 --- a/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.html +++ b/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.html @@ -17,7 +17,7 @@ - @if (!isStatCard() && isCategoryFilterable()) { + @if (isCategoryFilterable()) { @let categoryControls = form.controls.categories.controls; Categories @@ -36,8 +36,17 @@ + @if (noneSelected()) { +
+ Select all +
+ } @else { +
+ Deselect all +
+ } @for (category of filteredCategories(); track category.id) { - + {{ category.icon }} {{ category.name }} diff --git a/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.ts b/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.ts index 192aa91c..de42d454 100644 --- a/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.ts +++ b/frontend/Exence/src/app/private/statistics/edit-chart-dialog/edit-chart-dialog.component.ts @@ -8,7 +8,6 @@ import { Category } from '../../../data-model/modules/category/Category'; import { CategoryType } from '../../../data-model/modules/category/CategoryType'; import { CATEGORY_FILTERABLE_WIDGET_TYPES, - GROUP_WIDGET_TYPES, WIDGET_CATEGORY_TYPES, WidgetType, } from '../../../data-model/modules/statistics/widget-config.model'; @@ -23,6 +22,7 @@ import { SelectAutoFocusDirective } from '../../../shared/select-auto-focus.dire import { toRawValueSignal } from '../../../shared/util/utils'; import { ValidatorComponent } from '../../../shared/validator/validator.component'; import { CategoryService } from '../../transactions-and-categories/category.service'; +import { ExtraValidators } from '../../../shared/validators'; export interface EditChartDialogData { title: string; @@ -59,16 +59,19 @@ export class EditChartDialogComponent extends DialogComponent(this.data.title, [Validators.required, Validators.maxLength(255)]), categories: this.fb.group({ - selectedCategories: this.fb.control([]), + selectedCategories: this.fb.control( + (this.data.settings?.[WidgetSetting.CATEGORY_IDS] as number[] | undefined) ?? [], + [Validators.required, ExtraValidators.filledArray], + ), searchText: this.fb.control('', [Validators.maxLength(25)]), }), }); + selectedCategoriesValue = toRawValueSignal(this.form.controls.categories.controls.selectedCategories); private categories = signal([]); private searchText = toRawValueSignal(this.form.controls.categories.controls.searchText); @@ -85,10 +88,10 @@ export class EditChartDialogComponent extends DialogComponent c.name.toLowerCase().includes(search.toLowerCase())); }); - isStatCard = computed(() => this.cardTypes.includes(this.data.type)); - isCategoryFilterable = computed(() => this.categoryFilterableWidgets.includes(this.data.type)); + noneSelected = computed(() => this.selectedCategoriesValue().length === 0); + constructor() { super(inject(DialogRef)); this.categoryService.list().then(categories => this.categories.set(categories)); @@ -101,9 +104,7 @@ export class EditChartDialogComponent extends DialogComponent; if (this.isCategoryFilterable()) { - const categoryIds = formValue.categories.selectedCategories - .map(c => c.id) - .filter((id): id is number => id !== undefined); + const categoryIds = formValue.categories.selectedCategories; if (categoryIds.length > 0) { settings[WidgetSetting.CATEGORY_IDS] = categoryIds; } else { @@ -113,4 +114,12 @@ export class EditChartDialogComponent extends DialogComponent c.id!)); + } + + deselectAll(): void { + this.form.controls.categories.controls.selectedCategories.setValue([]); + } } diff --git a/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.html b/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.html index 7c520e23..f63ab388 100644 --- a/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.html +++ b/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.html @@ -54,7 +54,7 @@

{{ widget.title }}

- @if (!isStatCard() && isCategoryFilterable()) { + @if (isCategoryFilterable()) { @let categoryControls = form.controls.categories.controls; Categories @@ -78,8 +78,17 @@

{{ widget.title }}

+ @if (noneSelected()) { +
+ Select all +
+ } @else { +
+ Deselect all +
+ } @for (category of filteredCategories(); track category.id) { - + {{ category.icon }} {{ category.name }} diff --git a/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.ts b/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.ts index c65d3cbd..1c0fcf6f 100644 --- a/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.ts +++ b/frontend/Exence/src/app/private/statistics/widget-catalog-dialog/widget-catalog-dialog.component.ts @@ -31,6 +31,7 @@ import { SelectAutoFocusDirective } from '../../../shared/select-auto-focus.dire import { toRawValueSignal } from '../../../shared/util/utils'; import { ValidatorComponent } from '../../../shared/validator/validator.component'; import { CategoryService } from '../../transactions-and-categories/category.service'; +import { ExtraValidators } from '../../../shared/validators'; export interface WidgetCatalogDialogData { statCards: StatCardWidget[]; @@ -89,10 +90,11 @@ export class WidgetCatalogDialogComponent extends DialogComponent< form = this.fb.group({ title: this.fb.control('', [Validators.required, Validators.maxLength(255)]), categories: this.fb.group({ - selectedCategories: this.fb.control([]), + selectedCategories: this.fb.control([], [Validators.required, ExtraValidators.filledArray]), searchText: this.fb.control('', [Validators.maxLength(25)]), }), }); + selectedCategoriesValue = toRawValueSignal(this.form.controls.categories.controls.selectedCategories); private categories = signal([]); private searchText = toRawValueSignal(this.form.controls.categories.controls.searchText); @@ -110,11 +112,6 @@ export class WidgetCatalogDialogComponent extends DialogComponent< return categories.filter(c => c.name.toLowerCase().includes(search.toLowerCase())); }); - isStatCard = computed(() => { - const selected = this.selectedWidgetValue(); - return selected ? this.cardTypes.includes(selected.type) : false; - }); - isCategoryFilterable = computed(() => { const selected = this.selectedWidgetValue(); return selected ? this.categoryFilterableWidgets.includes(selected.type) : false; @@ -125,6 +122,8 @@ export class WidgetCatalogDialogComponent extends DialogComponent< return s.selectedIndex === s.steps.length - 1; }); + noneSelected = computed(() => this.selectedCategoriesValue().length === 0); + constructor() { super(inject(DialogRef)); this.categoryService.list().then(categories => this.categories.set(categories)); @@ -133,6 +132,10 @@ export class WidgetCatalogDialogComponent extends DialogComponent< const selected = this.selectedWidgetValue(); this.form.controls.title.setValue(selected?.title ?? ''); }); + + effect(() => + this.form.controls.categories.controls.selectedCategories.setValue(this.categories().map(c => c.id!)), + ); } statCardSelectionDisabled(type: WidgetType): boolean { @@ -147,9 +150,7 @@ export class WidgetCatalogDialogComponent extends DialogComponent< const settings = {} as Record; if (this.isCategoryFilterable()) { - const categoryIds = formValue.categories.selectedCategories - .map(c => c.id) - .filter((id): id is number => id !== undefined); + const categoryIds = formValue.categories.selectedCategories; if (categoryIds.length > 0) { settings[WidgetSetting.CATEGORY_IDS] = categoryIds; } @@ -171,4 +172,12 @@ export class WidgetCatalogDialogComponent extends DialogComponent< previousStep(): void { this.stepper().previous(); } + + selectAll(): void { + this.form.controls.categories.controls.selectedCategories.setValue(this.filteredCategories().map(c => c.id!)); + } + + deselectAll(): void { + this.form.controls.categories.controls.selectedCategories.setValue([]); + } } diff --git a/frontend/Exence/src/app/shared/validator/validator.component.html b/frontend/Exence/src/app/shared/validator/validator.component.html index 57b08830..a2a73cf8 100644 --- a/frontend/Exence/src/app/shared/validator/validator.component.html +++ b/frontend/Exence/src/app/shared/validator/validator.component.html @@ -21,5 +21,8 @@ @case ('max') { Maximum value is {{ errorValue()?.max }}! } + @case ('filledArray') { + You have to choose minimum 1 value! + } } } diff --git a/frontend/Exence/src/app/shared/validators.ts b/frontend/Exence/src/app/shared/validators.ts index ecb67572..6f4deb0d 100644 --- a/frontend/Exence/src/app/shared/validators.ts +++ b/frontend/Exence/src/app/shared/validators.ts @@ -32,4 +32,9 @@ export class ExtraValidators { passwordRegex.lengthInterval.test(value); return pass ? null : { password: true }; } + + static filledArray(control: AbstractControl): ValidationErrors | null { + const value = control.value; + return Array.isArray(value) && value.length > 0 ? null : { filledArray: true }; + } }