Add widgets, Add category import/export

This commit is contained in:
Miroslav Vasilev 2024-01-03 17:32:13 +02:00
parent c6c44e1604
commit 6b0f3828a3
33 changed files with 1386 additions and 69 deletions

2
.idea/dataSources.xml generated
View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
<data-source source="LOCAL" name="finances@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>

2
.idea/misc.xml generated
View file

@ -4,7 +4,7 @@
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="openjdk-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View file

@ -12,6 +12,7 @@ repositories {
dependencies {
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
implementation 'org.springframework:spring-web:6.1.1'
implementation 'org.springframework.data:spring-data-jpa:3.2.0'
testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'

View file

@ -3,7 +3,9 @@ package dev.mvvasilev.common.controller;
import dev.mvvasilev.common.web.APIErrorDTO;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.common.web.CrudResponseDTO;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Collection;
@ -24,11 +26,11 @@ public abstract class AbstractRestController {
return withStatus(HttpStatus.OK, body);
}
protected <T> ResponseEntity<APIResponseDTO<Object>> emptySuccess() {
protected ResponseEntity<APIResponseDTO<Object>> emptySuccess() {
return withStatus(HttpStatus.OK, null);
}
protected <T> ResponseEntity<APIResponseDTO<CrudResponseDTO>> created(Long id) {
protected ResponseEntity<APIResponseDTO<CrudResponseDTO>> created(Long id) {
return withStatus(HttpStatus.CREATED, new CrudResponseDTO(id, null));
}
@ -49,4 +51,10 @@ public abstract class AbstractRestController {
return withStatus(HttpStatus.OK, new CrudResponseDTO(null, affectedRows));
}
protected ResponseEntity<APIResponseDTO<byte[]>> file(byte[] fileBytes, MediaType mediaType) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
return new ResponseEntity<>(new APIResponseDTO<>(fileBytes, null, HttpStatus.OK.value(), HttpStatus.OK.getReasonPhrase()), headers, HttpStatus.OK);
}
}

View file

@ -0,0 +1,15 @@
package dev.mvvasilev.common.data;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
public interface UserOwnedEntityRepository<T, ID> extends JpaRepository<T, ID> {
Page<T> findAllByUserId(int userId, Pageable pageable);
Collection<T> findAllByUserId(int userId);
}

View file

@ -1,5 +1,7 @@
package dev.mvvasilev.finances.controllers;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.common.web.CrudResponseDTO;
@ -7,11 +9,15 @@ import dev.mvvasilev.finances.dtos.*;
import dev.mvvasilev.finances.enums.CategorizationRule;
import dev.mvvasilev.finances.services.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collection;
@ -21,9 +27,12 @@ public class CategoriesController extends AbstractRestController {
final private CategoryService categoryService;
final private ObjectMapper objectMapper;
@Autowired
public CategoriesController(CategoryService categoryService) {
public CategoriesController(CategoryService categoryService, ObjectMapper objectMapper) {
this.categoryService = categoryService;
this.objectMapper = objectMapper;
}
@GetMapping("/rules")
@ -80,14 +89,25 @@ public class CategoriesController extends AbstractRestController {
return created(categoryService.createCategorizationRules(categoryId, Integer.parseInt(authentication.getName()), dto));
}
// @DeleteMapping("/{categoryId}/rules/{ruleId}")
// @PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
// public ResponseEntity<APIResponseDTO<CrudResponseDTO>> deleteCategorizationRule(
// @PathVariable("categoryId") Long categoryId,
// @PathVariable("ruleId") Long ruleId
// ) {
// return deleted(categoryService.deleteCategorizationRule(ruleId));
// }
@GetMapping("/export")
public ResponseEntity<APIResponseDTO<byte[]>> exportCategories(Authentication authentication) throws JsonProcessingException {
var json = objectMapper.writeValueAsString(categoryService.exportCategoriesForUser(Integer.parseInt(authentication.getName())));
return file(json.getBytes(Charset.defaultCharset()), MediaType.APPLICATION_JSON);
}
@PostMapping("/import")
public ResponseEntity<APIResponseDTO<Collection<CrudResponseDTO>>> importCategories(
@RequestParam("file") MultipartFile file,
@RequestParam("deleteExisting") Boolean deleteExisting,
Authentication authentication
) throws IOException {
return created(categoryService.importCategoriesForUser(
objectMapper.readValue(file.getBytes(), ImportExportCategoriesDTO.class),
deleteExisting,
Integer.parseInt(authentication.getName())
));
}
@PostMapping("/categorize")
public ResponseEntity<APIResponseDTO<Object>> categorizeTransactions(Authentication authentication) {

View file

@ -29,8 +29,13 @@ public class StatisticsController extends AbstractRestController {
this.statisticsService = statisticsService;
}
@GetMapping("/timePeriods")
public ResponseEntity<APIResponseDTO<TimePeriod[]>> fetchTimePeriods() {
return ok(TimePeriod.values());
}
@GetMapping("/totalSpendingByCategory")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
public ResponseEntity<APIResponseDTO<SpendingByCategoriesDTO>> fetchSpendingByCategory(
Long[] categoryId,
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
@ -40,7 +45,7 @@ public class StatisticsController extends AbstractRestController {
}
@GetMapping("/spendingOverTimeByCategory")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.RawStatement))")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
public ResponseEntity<APIResponseDTO<SpendingOverTimeByCategoryDTO>> fetchSpendingOverTimeByCategory(
Long[] categoryId,
@RequestParam(defaultValue = "DAILY") TimePeriod period,

View file

@ -3,18 +3,52 @@ package dev.mvvasilev.finances.controllers;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.common.web.CrudResponseDTO;
import dev.mvvasilev.finances.dtos.CreateUpdateWidgetDTO;
import dev.mvvasilev.finances.dtos.WidgetDTO;
import dev.mvvasilev.finances.enums.WidgetType;
import dev.mvvasilev.finances.services.WidgetService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Collection;
@RestController
@RequestMapping("/widgets")
public class WidgetsController extends AbstractRestController {
// <CreateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> create(CreateDTO dto);
//
// <UpdateDTO> ResponseEntity<APIResponseDTO<CrudResponseDTO>> update(Long id, UpdateDTO dto);
//
// ResponseEntity<APIResponseDTO<CrudResponseDTO>> delete(Long id);
private final WidgetService widgetService;
public WidgetsController(WidgetService widgetService) {
this.widgetService = widgetService;
}
@GetMapping("/types")
public ResponseEntity<APIResponseDTO<WidgetType[]>> fetchWidgetTypes() {
return ok(WidgetType.values());
}
@GetMapping
public ResponseEntity<APIResponseDTO<Collection<WidgetDTO>>> fetchAllForUser(Authentication authentication) {
return ok(widgetService.fetchAllForUser(Integer.parseInt(authentication.getName())));
}
@PostMapping
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> create(@RequestBody CreateUpdateWidgetDTO dto, Authentication authentication) {
return created(widgetService.createWidget(dto, Integer.parseInt(authentication.getName())));
}
@PutMapping("/{widgetId}")
@PreAuthorize("@authService.isOwner(#widgetId, T(dev.mvvasilev.finances.entity.Widget))")
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> update(@PathVariable Long widgetId, @RequestBody CreateUpdateWidgetDTO dto) {
return updated(widgetService.updateWidget(widgetId, dto));
}
@DeleteMapping("/{widgetId}")
@PreAuthorize("@authService.isOwner(#widgetId, T(dev.mvvasilev.finances.entity.Widget))")
public ResponseEntity<APIResponseDTO<CrudResponseDTO>> delete(@PathVariable Long widgetId) {
return deleted(widgetService.deleteWidget(widgetId));
}
}

View file

@ -7,6 +7,8 @@ import java.time.LocalDateTime;
public record CategorizationDTO(
Long id,
Long categoryId,
CategorizationRuleDTO rule,
ProcessedTransactionFieldDTO ruleBasedOn,

View file

@ -0,0 +1,26 @@
package dev.mvvasilev.finances.dtos;
import dev.mvvasilev.finances.enums.WidgetType;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.Length;
import java.util.List;
public record CreateUpdateWidgetDTO(
@Length(max = 255, message = "Length of name cannot be more than 255 characters.")
@NotNull(message = "Widget must have name.")
String name,
@Min(value = 0, message = "Horizontal position of widget cannot be less than 0.")
Integer positionX,
@Min(value = 0, message = "Vertical position of widget cannot be less than 0.")
Integer positionY,
@Min(value = 1, message = "Horizontal size of widget cannot be less than 1.")
Integer sizeX,
@Min(value = 1, message = "Vertical size of widget cannot be less than 1.")
Integer sizeY,
@NotNull(message = "Widget must have type.")
WidgetType type,
@NotNull(message = "Widget must have parameters, even if empty.")
List<CreateWidgetParameterDTO> parameters
) {}

View file

@ -0,0 +1,11 @@
package dev.mvvasilev.finances.dtos;
import java.time.LocalDateTime;
public record CreateWidgetParameterDTO (
String name,
String stringValue,
Double numericValue,
LocalDateTime timestampValue,
Boolean booleanValue
) {}

View file

@ -0,0 +1,9 @@
package dev.mvvasilev.finances.dtos;
import java.util.Collection;
public record ImportExportCategoriesDTO(
Collection<CategoryDTO> categories,
Collection<CategorizationDTO> categorizationRules
) {}

View file

@ -0,0 +1,16 @@
package dev.mvvasilev.finances.dtos;
import dev.mvvasilev.finances.enums.WidgetType;
import java.util.List;
public record WidgetDTO (
Long id,
String name,
int positionX,
int positionY,
int sizeX,
int sizeY,
WidgetType type,
List<WidgetParameterDTO> parameters
) {}

View file

@ -0,0 +1,12 @@
package dev.mvvasilev.finances.dtos;
import java.time.LocalDateTime;
public record WidgetParameterDTO (
Long id,
String name,
String stringValue,
Double numericValue,
LocalDateTime timestampValue,
Boolean booleanValue
) {}

View file

@ -1,6 +1,7 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import dev.mvvasilev.common.data.UserOwned;
import dev.mvvasilev.finances.enums.WidgetType;
import jakarta.persistence.Convert;
import jakarta.persistence.Converter;
@ -9,21 +10,23 @@ import jakarta.persistence.Table;
@Entity
@Table(schema = "widgets")
public class Widget extends AbstractEntity {
public class Widget extends AbstractEntity implements UserOwned {
@Convert(converter = WidgetType.JpaConverter.class)
private WidgetType type;
private int positionX;
private Integer positionX;
private int positionY;
private Integer positionY;
private int sizeX;
private Integer sizeX;
private int sizeY;
private Integer sizeY;
private String name;
private Integer userId;
public Widget() {
}
@ -35,35 +38,35 @@ public class Widget extends AbstractEntity {
this.type = type;
}
public int getPositionX() {
public Integer getPositionX() {
return positionX;
}
public void setPositionX(int positionX) {
public void setPositionX(Integer positionX) {
this.positionX = positionX;
}
public int getPositionY() {
public Integer getPositionY() {
return positionY;
}
public void setPositionY(int positionY) {
public void setPositionY(Integer positionY) {
this.positionY = positionY;
}
public int getSizeX() {
public Integer getSizeX() {
return sizeX;
}
public void setSizeX(int sizeX) {
public void setSizeX(Integer sizeX) {
this.sizeX = sizeX;
}
public int getSizeY() {
public Integer getSizeY() {
return sizeY;
}
public void setSizeY(int sizeY) {
public void setSizeY(Integer sizeY) {
this.sizeY = sizeY;
}
@ -74,4 +77,13 @@ public class Widget extends AbstractEntity {
public void setName(String name) {
this.name = name;
}
@Override
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
}

View file

@ -35,7 +35,7 @@ public class StatisticsRepository {
JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id
WHERE
pt.is_inflow = FALSE
AND ptc.category_id IN (?1)
AND ptc.category_id = any(?1)
AND (pt.timestamp BETWEEN ?2 AND ?3)
GROUP BY ptc.category_id
ORDER BY total_spending DESC;

View file

@ -38,4 +38,6 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
nativeQuery = true
)
Collection<TransactionCategory> fetchCategoriesForTransaction(@Param("transactionId") Long transactionId);
void deleteAllByUserId(@Param("userId") Integer userId);
}

View file

@ -0,0 +1,15 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.finances.entity.WidgetParameter;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
@Repository
public interface WidgetParameterRepository extends JpaRepository<WidgetParameter, Long> {
Collection<WidgetParameter> findAllByWidgetIdIn(Collection<Long> widgetIds);
}

View file

@ -0,0 +1,10 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.common.data.UserOwnedEntityRepository;
import dev.mvvasilev.finances.entity.Widget;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface WidgetRepository extends UserOwnedEntityRepository<Widget, Long> {
}

View file

@ -281,6 +281,7 @@ public class CategoryService {
private CategorizationDTO mapCategorization(final Collection<Categorization> all, Categorization categorization) {
return new CategorizationDTO(
categorization.getId(),
categorization.getCategoryId(),
new CategorizationRuleDTO(
categorization.getCategorizationRule(),
categorization.getCategorizationRule().applicableForType()
@ -350,4 +351,56 @@ public class CategoryService {
return categorizationRepository.saveAndFlush(categorization);
}
public ImportExportCategoriesDTO exportCategoriesForUser(int userId) {
final var categories = listForUser(userId);
return new ImportExportCategoriesDTO(
categories,
categories.stream().map(c -> fetchCategorizationRules(c.id())).flatMap(Collection::stream).toList()
);
}
public Collection<Long> importCategoriesForUser(ImportExportCategoriesDTO dto, boolean deleteExisting, int userId) {
if (deleteExisting) {
transactionCategoryRepository.deleteAllByUserId(userId);
}
return dto.categories().stream().map(c -> {
var category = new TransactionCategory();
category.setUserId(userId);
category.setName(c.name());
category.setRuleBehavior(c.ruleBehavior());
category = transactionCategoryRepository.saveAndFlush(category);
createCategorizationRules(
category.getId(),
category.getUserId(),
dto.categorizationRules().stream()
.filter(cr -> Objects.equals(cr.categoryId(), c.id()))
.map(this::mapCategorizationForImport)
.toList()
);
return category.getId();
}).toList();
}
private CreateCategorizationDTO mapCategorizationForImport(CategorizationDTO cr) {
return new CreateCategorizationDTO (
cr.rule().rule(),
cr.ruleBasedOn().field(),
Optional.ofNullable(cr.stringValue()),
Optional.ofNullable(cr.numericGreaterThan()),
Optional.ofNullable(cr.numericLessThan()),
Optional.ofNullable(cr.numericValue()),
Optional.ofNullable(cr.timestampGreaterThan()),
Optional.ofNullable(cr.timestampLessThan()),
Optional.ofNullable(cr.booleanValue()),
cr.left() != null ? mapCategorizationForImport(cr.left()) : null,
cr.right() != null ? mapCategorizationForImport(cr.right()) : null
);
}
}

View file

@ -0,0 +1,124 @@
package dev.mvvasilev.finances.services;
import dev.mvvasilev.common.exceptions.CommonFinancesException;
import dev.mvvasilev.finances.dtos.CreateUpdateWidgetDTO;
import dev.mvvasilev.finances.dtos.CreateWidgetParameterDTO;
import dev.mvvasilev.finances.dtos.WidgetDTO;
import dev.mvvasilev.finances.dtos.WidgetParameterDTO;
import dev.mvvasilev.finances.entity.Widget;
import dev.mvvasilev.finances.entity.WidgetParameter;
import dev.mvvasilev.finances.persistence.WidgetParameterRepository;
import dev.mvvasilev.finances.persistence.WidgetRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional
public class WidgetService {
private final WidgetRepository widgetRepository;
private final WidgetParameterRepository widgetParameterRepository;
public WidgetService(WidgetRepository widgetRepository, WidgetParameterRepository widgetParameterRepository) {
this.widgetRepository = widgetRepository;
this.widgetParameterRepository = widgetParameterRepository;
}
public long createWidget(CreateUpdateWidgetDTO dto, int userId) {
final var savedWidget = widgetRepository.saveAndFlush(mapWidget(new Widget(), userId, dto));
final var params = dto.parameters()
.stream()
.map(p -> mapWidgetParameter(new WidgetParameter(), savedWidget.getId(), p))
.toList();
widgetParameterRepository.saveAllAndFlush(params);
return savedWidget.getId();
}
public int updateWidget(Long id, CreateUpdateWidgetDTO dto) {
var widget = widgetRepository.findById(id);
if (widget.isEmpty()) {
throw new CommonFinancesException("No widget with id %d exists.", id);
}
widgetRepository.saveAndFlush(mapWidget(widget.get(), widget.get().getUserId(), dto));
return 1; // TODO: fetch rows affected from database
}
public int deleteWidget(Long id) {
widgetRepository.deleteById(id);
return 1; // TODO: fetch rows affected from database
}
private Widget mapWidget(Widget widget, int userId, CreateUpdateWidgetDTO dto) {
widget.setName(dto.name());
widget.setType(dto.type());
widget.setUserId(userId);
widget.setPositionX(dto.positionX());
widget.setPositionY(dto.positionY());
widget.setSizeX(dto.sizeX());
widget.setSizeY(dto.sizeY());
return widget;
}
private WidgetDTO mapWidgetDTO(Widget widget, List<WidgetParameterDTO> params) {
return new WidgetDTO(
widget.getId(),
widget.getName(),
widget.getPositionX(),
widget.getPositionY(),
widget.getSizeX(),
widget.getSizeY(),
widget.getType(),
params
);
}
private WidgetParameter mapWidgetParameter(WidgetParameter widgetParameter, Long widgetId, CreateWidgetParameterDTO dto) {
widgetParameter.setWidgetId(widgetId);
widgetParameter.setName(dto.name());
widgetParameter.setBooleanValue(dto.booleanValue());
widgetParameter.setNumericValue(dto.numericValue());
widgetParameter.setTimestampValue(dto.timestampValue());
widgetParameter.setStringValue(dto.stringValue());
return widgetParameter;
}
private WidgetParameterDTO mapWidgetParameterDTO(WidgetParameter widgetParameter) {
return new WidgetParameterDTO(
widgetParameter.getWidgetId(),
widgetParameter.getName(),
widgetParameter.getStringValue(),
widgetParameter.getNumericValue(),
widgetParameter.getTimestampValue(),
widgetParameter.getBooleanValue()
);
}
public Collection<WidgetDTO> fetchAllForUser(int userId) {
final var widgets = widgetRepository.findAllByUserId(userId);
final var widgetParams = widgetParameterRepository.findAllByWidgetIdIn(widgets.stream().map(Widget::getId).toList());
return widgets.stream().map(w -> {
final var paramDTOs = widgetParams.stream()
.filter(wp -> wp.getWidgetId() == w.getId())
.map(this::mapWidgetParameterDTO)
.toList();
return mapWidgetDTO(w, paramDTOs);
})
.toList();
}
}

View file

@ -11,6 +11,9 @@ spring.datasource.username=${DATASOURCE_USER}
spring.datasource.password=${DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.properties.hibernate.jdbc.batch_size=10
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.generate-ddl=false
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=validate

View file

@ -1,3 +1,5 @@
CREATE SCHEMA IF NOT EXISTS statistics;
CREATE OR REPLACE FUNCTION statistics.spending_over_time(
category_ids BIGINT[],
time_period TEXT,

View file

@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS widgets.widget (
sizeX INTEGER,
sizeY INTEGER,
name VARCHAR(255),
user_id INTEGER,
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_widget PRIMARY KEY (id)

View file

@ -9,5 +9,5 @@ CREATE TABLE IF NOT EXISTS widgets.widget_parameter (
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_widget_parameter PRIMARY KEY (id),
CONSTRAINT FK_widget_parameter_widget FOREIGN KEY (widget_id) REFERENCES widgets.widget(id)
CONSTRAINT FK_widget_parameter_widget FOREIGN KEY (widget_id) REFERENCES widgets.widget(id) ON DELETE CASCADE
);

View file

@ -16,10 +16,13 @@
"@mui/x-data-grid": "^6.18.6",
"@mui/x-date-pickers": "^6.18.6",
"@mui/x-tree-view": "^6.17.0",
"chart.js": "^4.4.1",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hot-toast": "^2.4.1",
"react-material-ui-carousel": "^3.4.2",
"react-router-dom": "^6.21.0",
@ -1096,6 +1099,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@mui/base": {
"version": "5.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.27.tgz",
@ -2111,6 +2119,17 @@
"node": ">=4"
}
},
"node_modules/chart.js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz",
"integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=7"
}
},
"node_modules/clsx": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
@ -2769,6 +2788,11 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -4094,6 +4118,15 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz",
"integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@ -4106,6 +4139,44 @@
"react": "^18.2.0"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-grid-layout": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz",
"integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==",
"dependencies": {
"clsx": "^2.0.0",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.5",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hot-toast": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz",
@ -4157,6 +4228,18 @@
"node": ">=0.10.0"
}
},
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-router": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.0.tgz",
@ -4249,6 +4332,11 @@
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",

View file

@ -18,10 +18,13 @@
"@mui/x-data-grid": "^6.18.6",
"@mui/x-date-pickers": "^6.18.6",
"@mui/x-tree-view": "^6.17.0",
"chart.js": "^4.4.1",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.4.4",
"react-hot-toast": "^2.4.1",
"react-material-ui-carousel": "^3.4.2",
"react-router-dom": "^6.21.0",

View file

@ -10,6 +10,7 @@ import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import {Analytics as AnalyticsIcon} from '@mui/icons-material';
import {Home as HomeIcon} from '@mui/icons-material';
import {ReceiptLong as StatementsIcon} from '@mui/icons-material';
import {Receipt as TransactionsIcon} from '@mui/icons-material';
@ -27,7 +28,7 @@ import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs";
const DRAWER_WIDTH = 240;
const NAV_LINKS = [
{text: 'Home', to: '/', icon: HomeIcon},
{text: 'Statistics', to: '/', icon: AnalyticsIcon},
{text: 'Statements', to: '/statements', icon: StatementsIcon},
{text: 'Categories', to: '/categories', icon: CategoryIcon},
{text: 'Transactions', to: '/transactions', icon: TransactionsIcon},

View file

@ -2,7 +2,7 @@ import {
Category as CategoryIcon,
Add as AddIcon,
Close as CloseIcon,
Save as SaveIcon
Save as SaveIcon, Download, Upload
} from "@mui/icons-material";
import {useEffect, useState} from "react";
import utils from "@/utils.js";
@ -11,17 +11,21 @@ import Grid from "@mui/material/Unstable_Grid2";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import {
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
DialogTitle, FormControlLabel,
Modal,
TextField
} from "@mui/material";
import Box from "@mui/material/Box";
import CategorizationRulesEditor from "@/components/categories/CategorizationRulesEditor.jsx";
import CategoriesBox from "@/components/categories/CategoriesBox.jsx";
import {PARAMS} from "@/components/widgets/WidgetParameters.js";
import * as React from "react";
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
export default function CategoriesPage() {
@ -31,6 +35,8 @@ export default function CategoriesPage() {
const [showConfirmDeleteCategoryModal, openConfirmDeleteCategoryModal] = useState(false);
const [showApplyRulesConfirmModal, openApplyRulesConfirmModal] = useState(false);
const [newCategoryName, setNewCategoryName] = useState(null);
const [showUploadDialog, openUploadDialog] = useState(false);
const [replaceExistingOnUpload, setReplaceExistingOnUpload] = useState(true);
useEffect(() => {
utils.showSpinner();
@ -155,6 +161,61 @@ export default function CategoriesPage() {
});
}
function downloadCategories() {
toast.promise(
utils.performRequest("/api/categories/export")
.then(resp => resp.json())
.then(resp => {
const linkSource = `data:application/json;base64,${resp.result}`;
const downloadLink = document.createElement("a");
const fileName = "categories.json";
downloadLink.href = linkSource;
downloadLink.download = fileName;
downloadLink.click();
}),
{
loading: "Exporting...",
success: "Exported",
error: (err) => `Uh oh! Something went wrong: ${err}`
}
)
}
function uploadCategories(e) {
utils.showSpinner();
if (!e.target.files) {
return;
}
const file = e.target.files[0];
const formData = new FormData();
formData.append("file", file);
formData.append("deleteExisting", replaceExistingOnUpload);
toast.promise(
utils.performRequest("/api/categories/import", {
method: "POST",
body: formData
}).then(resp => {
openUploadDialog(false);
utils.hideSpinner();
}),
{
loading: "Importing...",
success: "Imported",
error: (err) => {
utils.hideSpinner();
openUploadDialog(false);
return `Uh oh! Something went wrong: ${err}`;
}
}
);
}
return (
<Grid container spacing={1}>
@ -169,7 +230,17 @@ export default function CategoriesPage() {
Apply Rules
</Button>
</Grid>
<Grid xs={10} lg={10}></Grid>
<Grid xs={1} lg={1}>
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<Download />} onClick={() => downloadCategories()}>
Export
</Button>
</Grid>
<Grid xs={1} lg={1}>
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<Upload />} onClick={() => openUploadDialog(true)}>
Import
</Button>
</Grid>
<Grid xs={8} lg={8}></Grid>
</Grid>
<Grid xs={12} lg={12}>
@ -307,6 +378,48 @@ export default function CategoriesPage() {
</Button>
</DialogActions>
</Dialog>
<Dialog
open={showUploadDialog}
>
<DialogTitle id="upload-dialog-title">
{"Replace Existing Categories?"}
</DialogTitle>
<DialogContent>
<DialogContentText>
Would you like to replace your existing categories completely with the ones in your import?
( Note that this will remove all current categories from your transactions )
</DialogContentText>
<FormControlLabel
sx={{ width: "100%", height: "100%" }}
value="end"
control={
<Checkbox
checked={replaceExistingOnUpload}
onChange={(e) => {
setReplaceExistingOnUpload(e.target.checked);
}}
/>
}
label="Replace Existing Categories"
labelPlacement="end"
/>
</DialogContent>
<DialogActions>
<Button
component="label"
>
<VisuallyHiddenInput type="file" onChange={uploadCategories}/>
Select File
</Button>
<Button
onClick={() => openUploadDialog(false)}
autoFocus
variant="contained"
>
Cancel
</Button>
</DialogActions>
</Dialog>
</Grid>
);
}

View file

@ -1,33 +1,277 @@
import * as React from 'react';
import Grid from '@mui/material/Unstable_Grid2';
import MediaCard from '@/components/MediaCard';
import { Stack } from '@mui/material';
import {Responsive, WidthProvider} from "react-grid-layout";
import {
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle,
Stack,
} from '@mui/material';
import Grid from "@mui/material/Unstable_Grid2";
import Button from "@mui/material/Button";
import {Addchart, Save} from "@mui/icons-material";
import WidgetContainer from "@/components/widgets/WidgetContainer.jsx";
import 'react-grid-layout/css/styles.css';
import {useEffect, useState} from "react";
import WidgetEditModal from "@/components/widgets/WidgetEditModal.jsx";
import utils from "@/utils.js";
import toast from "react-hot-toast";
const ResponsiveGridLayout = WidthProvider(Responsive);
export default function StatisticsPage() {
return (
<Stack>
<div>
<Grid container rowSpacing={3} columnSpacing={3}>
<Grid xs={4}>
<MediaCard
heading="CMYK"
text="The CMYK color model (also known as process color, or four color) is a subtractive color model, based on the CMY color model, used in color printing, and is also used to describe the printing process itself."
const [layout, setLayout] = useState([]);
const [widgets, setWidgets] = useState([]);
const [editedWidget, setEditedWidget] = useState({});
const [isWidgetModalOpen, openWidgetModal] = useState(false);
const [isRemoveWidgetDialogShown, showRemoveWidgetDialog] = useState(false);
const [removingWidgetId, setRemovingWidgetId] = useState(null);
useEffect(() => {
fetchWidgets();
}, []);
function fetchWidgets() {
utils.showSpinner();
return utils.performRequest("/api/widgets")
.then(resp => resp.json())
.then(resp => setWidgets((resp.result ?? [])?.map(w => {
return {
i: utils.generateUUID(),
dbId: w.id,
name: w.name,
type: w.type,
x: w.positionX,
y: w.positionY,
h: w.sizeY,
w: w.sizeX,
parameters: w.parameters
}
}) ?? []))
.then(r => utils.hideSpinner());
}
function openWidgetCreationModal() {
setEditedWidget({
i: utils.generateUUID(),
dbId: undefined,
x: 0,
y: 0,
w: 2,
h: 2
});
openWidgetModal(true);
}
function openWidgetEditModal(widget) {
setEditedWidget({...widget});
openWidgetModal(true);
}
async function createNewWidget(widget) {
utils.showSpinner();
await utils.performRequest("/api/widgets", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
positionX: widget.x,
positionY: widget.y,
sizeX: widget.w,
sizeY: widget.h,
name: widget.name,
type: widget.type,
parameters: widget.parameters
})
})
.then(resp => resp.json())
.then(resp => {
widget.dbId = resp.result.createdId;
setWidgets([...widgets, {...widget}]);
openWidgetModal(false);
utils.hideSpinner();
});
}
function saveWidgetsLayout() {
utils.showSpinner();
let promises = widgets.map(w => {
return utils.performRequest(`/api/widgets/${w.dbId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
positionX: w.x,
positionY: w.y,
sizeX: w.w,
sizeY: w.h,
name: w.name,
type: w.type,
parameters: w.parameters
})
})
});
toast.promise(
Promise.resolve(promises)
.then(r => fetchWidgets())
.then(r => utils.hideSpinner()),
{
loading: "Saving...",
success: "Saved",
error: (err) => `Uh oh, something went wrong!: ${err}`
}
);
}
function updateExistingWidget() {
}
function removeWidget() {
utils.showSpinner();
toast.promise(
utils.performRequest(`/api/widgets/${removingWidgetId}`, { method: "DELETE" })
.then(resp => fetchWidgets())
.then(resp => utils.hideSpinner())
.then(resp => showRemoveWidgetDialog(false)),
{
loading: "Deleting...",
success: "Deleted",
error: (err) => `Uh oh, something went wrong!: ${err}`
}
);
}
function saveLayout() {
}
function setEditedWidgetState(widget) {
setEditedWidget({...widget})
// setWidgets([...widgets.filter(w => w.id !== widget.id), {...widget}]);
}
return (
<Stack>
<Grid container spacing={1}>
<Grid container xs={12} lg={12}>
<Grid xs={1} lg={1}>
<Button
sx={{
width: "100%"
}}
variant="contained"
onClick={openWidgetCreationModal}
startIcon={<Addchart/>}
>
Add Widget
</Button>
</Grid>
<Grid xs={1} lg={1}>
<Button
sx={{
width: "100%"
}}
variant="contained"
onClick={saveWidgetsLayout}
startIcon={<Save/>}
>
Save Layout
</Button>
</Grid>
</Grid>
<Grid xs={12} lg={12}>
<ResponsiveGridLayout
onLayoutChange={layout => {
let newWidgets = layout.map(l => {
let widget = widgets.find(w => w.i === l.i);
widget.x = l.x;
widget.y = l.y;
widget.w = l.w;
widget.h = l.h;
return widget;
})
setWidgets([...newWidgets])
}}
draggableCancel=".grid-drag-cancel"
cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}}
breakpoints={{lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0}}
>
{
widgets.map((item) => (
<div
key={item.i}
data-grid={{
x: item.x,
y: item.y,
w: item.w,
h: item.h,
resizeHandles: ["se"]
}}
>
<WidgetContainer
widget={item}
onEdit={(e) => {
setEditedWidget({...item});
openWidgetModal(true);
}}
onRemove={(e) => {
setRemovingWidgetId(item.dbId);
showRemoveWidgetDialog(true);
}}
/>
</div>
))
}
</ResponsiveGridLayout>
</Grid>
</Grid>
<WidgetEditModal
initialWidget={editedWidget}
onCreate={createNewWidget}
onEdit={updateExistingWidget}
open={isWidgetModalOpen}
setOpen={openWidgetModal}
/>
</Grid>
<Grid xs={4}>
<MediaCard
heading="HSL and HSV"
text="HSL (for hue, saturation, lightness) and HSV (for hue, saturation, value; also known as HSB, for hue, saturation, brightness) are alternative representations of the RGB color model, designed in the 1970s by computer graphics researchers."
/>
</Grid>
<Grid xs={4}>
<MediaCard
heading="RGB"
text="An RGB color space is any additive color space based on the RGB color model. RGB color spaces are commonly found describing the input signal to display devices such as television screens and computer monitors."
/>
</Grid>
</Grid>
</div>
</Stack>
);
<Dialog
open={isRemoveWidgetDialogShown}
>
<DialogTitle id="delete-widget-dialog-title">
Delete This Widget?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a widget will permanently remove it from your dashboard and all parameters set for it will be lost.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={removeWidget}
>
Delete
</Button>
<Button
onClick={() => showRemoveWidgetDialog(false)}
autoFocus
variant="contained"
>
Cancel
</Button>
</DialogActions>
</Dialog>
</Stack>
);
}

View file

@ -0,0 +1,137 @@
import Card from "@mui/material/Card";
import * as React from "react";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Unstable_Grid2";
import {Close, Edit} from "@mui/icons-material";
import {IconButton} from "@mui/material";
import {useEffect, useState} from "react";
import utils from "@/utils.js";
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
import dayjs from "dayjs";
import 'chart.js/auto';
import { Pie } from 'react-chartjs-2';
export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
const [data, setData] = useState(null);
useEffect(() => {
switch (widget.type) {
case "TOTAL_SPENDING_PER_CATEGORY": {
var queryString = "";
console.log(widget);
queryString += widget.parameters?.filter(p => p.name.includes(PARAMS.CATEGORY_PREFIX)).map(c => `categoryId=${c.numericValue}`)?.join("&");
let isToNow = widget.parameters?.find(p => p.name === PARAMS.IS_TO_NOW)?.booleanValue ?? false;
let isFromStatic = widget.parameters?.find(p => p.name === PARAMS.IS_FROM_DATE_STATIC)?.booleanValue ?? false;
console.log(isToNow);
console.log(isFromStatic);
var fromDate;
var toDate;
if (isToNow) {
toDate = dayjs();
} else {
toDate = dayjs(widget.parameters?.find(p => p.name === PARAMS.TO_DATE)?.timestampValue);
}
if (!isFromStatic) {
fromDate = dayjs().subtract(widget.parameters?.find(p => p.name === PARAMS.RELATIVE_FROM_PERIOD)?.numericValue ?? 30, 'days');
} else {
fromDate = dayjs(widget.parameters?.find(p => p.name === PARAMS.FROM_DATE)?.timestampValue);
}
queryString += `&fromDate=${fromDate}`;
queryString += `&toDate=${toDate}`;
utils.performRequest(`/api/statistics/totalSpendingByCategory?${queryString}`)
.then(resp => resp.json())
.then(resp => setData(resp.result));
break;
}
case "SPENDING_OVER_TIME_PER_CATEGORY": {
utils.performRequest("/api/statistics/spendingOverTimeByCategory")
.then(resp => resp.json())
.then(resp => setData(resp.result));
break;
}
}
}, [widget]);
return (
<Card
sx={{
width: "100%",
height: "100%",
p: 2,
...sx
}}
>
<Grid container>
<Grid container xs={12} lg={12}>
<Grid xs={10} lg={10}>
<Typography
sx={{
width: "100%",
fontSize: "1.4em"
}}
>
{ widget.name }
</Typography>
</Grid>
<Grid xs={1} lg={1}>
<IconButton
sx={{ width: "100%" }}
onClick={onEdit}
className={"grid-drag-cancel"}
>
<Edit />
</IconButton>
</Grid>
<Grid xs={1} lg={1}>
<IconButton
sx={{ width: "100%" }}
onClick={onRemove}
className={"grid-drag-cancel"}
>
<Close />
</IconButton>
</Grid>
</Grid>
<Grid xs={12} lg={12}>
<Divider></Divider>
</Grid>
<Grid xs={12} lg={12}>
<div style={{ position: "relative", height: "100%", width: "100%" }}>
{
data && widget.type === "TOTAL_SPENDING_PER_CATEGORY" &&
<Pie
options={{
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1
}}
data={{
labels: data.categories.map(c => c.name),
datasets: [
{
label: "Amount",
data: data.categories.map(c => data.spendingByCategory[c.id])
}
]
}}
/>
}
</div>
</Grid>
</Grid>
</Card>
);
}

View file

@ -0,0 +1,341 @@
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Unstable_Grid2";
import {Checkbox, FormControlLabel, MenuItem, Modal, OutlinedInput, Select, Slider, TextField} from "@mui/material";
import Typography from "@mui/material/Typography";
import utils from "@/utils.js";
import {DatePicker} from "@mui/x-date-pickers";
import dayjs from "dayjs";
import Button from "@mui/material/Button";
import {Close as CloseIcon, Save as SaveIcon} from "@mui/icons-material";
import * as React from "react";
import {useEffect, useState} from "react";
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
export default function WidgetEditModal(
{
initialWidget,
onCreate,
onEdit,
open,
setOpen
}
) {
const [widget, setWidget] = useState({});
const [widgetTypes, setWidgetTypes] = useState([]);
const [categories, setCategories] = useState([]);
const [timePeriods, setTimePeriods] = useState([]);
useEffect(() => {
setWidget({
...initialWidget,
selectedCategories: initialWidget.parameters?.filter(p => p.name.includes("category")).map(p => p.numericValue) ?? []
});
}, [initialWidget]);
useEffect(() => {
utils.performRequest("/api/widgets/types")
.then(resp => resp.json())
.then(resp => setWidgetTypes(resp.result));
utils.performRequest("/api/categories")
.then(resp => resp.json())
.then(resp => setCategories(resp.result));
utils.performRequest("/api/statistics/timePeriods")
.then(resp => resp.json())
.then(resp => setTimePeriods(resp.result));
}, []);
function widgetParams(name, defaultValue) {
if (!widget.parameters) {
widget.parameters = [];
}
let val = widget.parameters?.find(p => p.name === name);
if (val) {
return val;
}
let newVal = {
name: name
};
defaultValue(newVal);
widget.parameters.push(newVal);
setWidget({...widget});
return newVal;
}
function setWidgetParam(name, setParam) {
var param = widget.parameters?.find(p => p.name === name);
if (!param) {
widget.parameters = [...widget.parameters];
widget.parameters.push({
name: name
});
param = widget.parameters?.find(p => p.name === name);
}
setParam(param);
setWidget({...widget});
}
function mapWidget() {
widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
return {
name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
numericValue: c
}
}));
widget.selectedCategories = undefined;
return {...widget}
}
function onEditWidget() {
onEdit(mapWidget());
}
function onCreateWidget() {
onCreate(mapWidget());
}
return (
widget &&
<Modal
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: 'translate(-50%, -50%)',
width: 400,
height: "fit-content",
p: 4
}}
open={open}
>
<Box>
{
widget.dbId ? (
<h3>Editing Widget</h3>
) : (
<h3>Create New Widget</h3>
)
}
<Divider></Divider>
<Grid container spacing={1}>
<Grid xs={12} lg={12}>
<TextField
id="widget-name"
label="Name"
variant="outlined"
value={widget.name ?? ""}
onChange={(e) => setWidget({
...widget,
name: e.target.value
})}
autoFocus
sx={{width: "100%"}}
/>
</Grid>
<Grid xs={12} lg={12}>
<Select
id={"widget-type"}
sx={{ width: "100%" }}
value={widget.type ?? "placeholder"}
onChange={(e) => setWidget({
...widget,
type: e.target.value
})}
>
<MenuItem value={"placeholder"} disabled>
<Typography sx={{ color: 'gray' }}>Type</Typography>
</MenuItem>
{
widgetTypes.map(wt => (
<MenuItem
key={wt}
value={wt}
>
<Typography>{ utils.toPascalCase(wt.replace(/_/g, " ")) }</Typography>
</MenuItem>
))
}
</Select>
</Grid>
<Grid xs={8} lg={8}>
{
widget.isFromDateStatic ? (
<DatePicker
sx={{ width: "100%", height: "100%" }}
label="From"
value={dayjs(widgetParams(PARAMS.FROM_DATE, val => val.timestampValue = dayjs())?.timestampValue)}
onChange={(newValue) => {
setWidgetParam(PARAMS.FROM_DATE, p => p.timestampValue = newValue);
}}
/>
) : (
<Slider
sx={{ width: "98%" }}
label={"From"}
min={-365}
scale={x => -x}
max={-7}
track="inverted"
valueLabelDisplay="auto"
valueLabelFormat={(val) => `${val} days`}
value={-widgetParams(PARAMS.RELATIVE_FROM_PERIOD, (val) => val.numericValue = 30)?.numericValue}
onChange={(e, newVal) => {
setWidgetParam(PARAMS.RELATIVE_FROM_PERIOD, (p) => p.numericValue = -newVal);
setWidget({...widget});
}}
/>
)
}
</Grid>
<Grid xs={4} lg={4}>
<FormControlLabel
sx={{ width: "100%", height: "100%" }}
value="end"
control={
<Checkbox
checked={widgetParams(PARAMS.IS_FROM_DATE_STATIC, val => val.booleanValue = false)?.booleanValue}
onChange={(e) => {
setWidgetParam(PARAMS.IS_FROM_DATE_STATIC, p => p.booleanValue = e.target.checked);
}}
/>
}
label="Static"
labelPlacement="end"
/>
</Grid>
<Grid xs={8} lg={8}>
<DatePicker
sx={{ width: "100%", height: "100%" }}
label="To"
value={dayjs(widgetParams(PARAMS.TO_DATE, val => val.timestampValue = dayjs())?.timestampValue)}
disabled={widgetParams(PARAMS.IS_TO_NOW, val => val.booleanValue = true)?.booleanValue}
onChange={(newValue) => {
setWidgetParam(PARAMS.TO_DATE, p => p.timestampValue = newValue);
}}
/>
</Grid>
<Grid xs={4} lg={4}>
<FormControlLabel
sx={{ width: "100%", height: "100%" }}
value="end"
control={
<Checkbox
checked={widgetParams(PARAMS.IS_TO_NOW, val => val.booleanValue = true)?.booleanValue}
onChange={(e) => {
setWidgetParam(PARAMS.IS_TO_NOW, p => p.booleanValue = e.target.checked);
}}
/>
}
label="To Now"
labelPlacement="end"
/>
</Grid>
{
widget.type === "SPENDING_OVER_TIME_PER_CATEGORY" &&
<Grid xs={12} lg={12}>
<Select
id={"time-period-type"}
sx={{ width: "100%" }}
value={widgetParams(PARAMS.TIME_PERIOD, val => val.stringValue = "placeholder")?.stringValue}
onChange={(e) => {
setWidgetParam(PARAMS.TIME_PERIOD, p => p.stringValue = e.target.value);
}}
>
<MenuItem value={"placeholder"} disabled>
<Typography sx={{ color: 'gray' }}>Time Period</Typography>
</MenuItem>
{
timePeriods.map(wt => (
<MenuItem
key={wt}
value={wt}
>
<Typography>{ utils.toPascalCase(wt.replace(/_/g, " ")) }</Typography>
</MenuItem>
))
}
</Select>
</Grid>
}
<Grid xs={12} lg={12}>
<Select
sx={{ width: "100%", height: "100%" }}
input={<OutlinedInput label="Categories" />}
multiple
value={widget.selectedCategories ?? []}
renderValue={(selected) => {
console.log(selected)
return (<Typography>
{ selected.map(s => categories.find(c => c.id === s)?.name)?.join(", ")}
</Typography>)
}}
onChange={(e) => {
widget.selectedCategories = e.target.value;
setWidget({...widget});
}}
>
<MenuItem value="placeholder" disabled>
<Typography sx={{ color: 'gray' }}>Categories</Typography>
</MenuItem>
{
categories.map(c => (
<MenuItem key={c.id} value={c.id}>
<Checkbox checked={widget.selectedCategories?.findIndex(cat => cat === c.id) > -1} />
<Typography>{ c.name }</Typography>
</MenuItem>
))
}
</Select>
</Grid>
<Grid xs={6} lg={6}>
{
widget.dbId ? (
<Button
sx={{width: "100%"}}
variant="contained"
onClick={onEditWidget}
startIcon={<SaveIcon />}
>
Update
</Button>
) : (
<Button
sx={{width: "100%"}}
variant="contained"
onClick={onCreateWidget}
startIcon={<SaveIcon />}
>
Create
</Button>
)
}
</Grid>
<Grid xs={6} lg={6}>
<Button
sx={{width: "100%"}}
onClick={() => setOpen(false)}
startIcon={<CloseIcon />}
>
Cancel
</Button>
</Grid>
</Grid>
</Box>
</Modal>
);
}

View file

@ -0,0 +1,9 @@
export const PARAMS = {
FROM_DATE: "fromDate",
TO_DATE: "toDate",
RELATIVE_FROM_PERIOD: "relativeFromPeriodInDays",
IS_FROM_DATE_STATIC: "isFromDateStatic",
IS_TO_NOW: "isToDateToNow",
TIME_PERIOD: "timePeriod",
CATEGORY_PREFIX: "category"
}