Add statements. Add transactions. Add processed transactions. Add visjs react component.

This commit is contained in:
Miroslav Vasilev 2023-12-19 00:23:35 +02:00
parent 6e190d09f0
commit 4748e388e0
41 changed files with 801 additions and 296 deletions

View file

@ -1,4 +0,0 @@
package dev.mvvasilev.common.data;
public class DataNamingStrategy {
}

View file

@ -21,7 +21,7 @@ public class APIResponseAdvice {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@ExceptionHandler(Exception.class)
public APIResponseDTO<Object> processGenericException(Exception ex) {
public ResponseEntity<APIResponseDTO<Object>> processGenericException(Exception ex) {
List<APIErrorDTO> errors = List.of(
new APIErrorDTO(
ex.getMessage(),
@ -32,11 +32,12 @@ public class APIResponseAdvice {
logger.error("Exception", ex);
return new APIResponseDTO<>(
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new APIResponseDTO<>(
null,
errors,
HttpStatus.INTERNAL_SERVER_ERROR.value(),
HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()
);
));
}
}

View file

@ -0,0 +1,62 @@
package dev.mvvasilev.finances.controllers;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
import dev.mvvasilev.finances.services.StatementsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Collection;
@RestController
@RequestMapping("/statements")
public class StatementsController extends AbstractRestController {
private StatementsService statementsService;
@Autowired
public StatementsController(StatementsService statementsService) {
this.statementsService = statementsService;
}
@GetMapping
public ResponseEntity<APIResponseDTO<Collection<UploadedStatementDTO>>> fetchStatements(Authentication authentication) {
return ResponseEntity.ofNullable(
ok(statementsService.fetchStatementsForUser(Integer.parseInt(authentication.getName())))
);
}
@GetMapping("/{statementId}/transactionValueGroups")
public ResponseEntity<APIResponseDTO<Collection<TransactionValueGroupDTO>>> fetchTransactionValueGroups(@PathVariable("statementId") Long statementId, Authentication authentication) {
return ResponseEntity.ofNullable(ok(
statementsService.fetchTransactionValueGroupsForUserStatement(statementId, Integer.parseInt(authentication.getName()))
));
}
@DeleteMapping("/{statementId}")
public ResponseEntity<APIResponseDTO> deleteStatement(@PathVariable("statementId") Long statementId, Authentication authentication) {
statementsService.deleteStatement(statementId, Integer.parseInt(authentication.getName()));
return ResponseEntity.ofNullable(ok(null));
}
@PostMapping(value = "/uploadSheet", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponseDTO<Integer>> uploadStatement(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException {
statementsService.uploadStatementFromExcelSheetForUser(
file.getOriginalFilename(),
file.getContentType(),
file.getInputStream(),
Integer.parseInt(authentication.getName())
);
return ResponseEntity.ofNullable(ok(1));
}
}

View file

@ -1,36 +0,0 @@
package dev.mvvasilev.finances.controllers;
import dev.mvvasilev.common.controller.AbstractRestController;
import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.finances.services.TransactionsService;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController("/transactions")
public class TransactionsController extends AbstractRestController {
private TransactionsService transactionsService;
@Autowired
public TransactionsController(TransactionsService transactionsService) {
this.transactionsService = transactionsService;
}
@PostMapping(value = "/transactions/uploadSheet", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponseDTO<Integer>> uploadTransactions(@RequestParam("file") MultipartFile file, Authentication authentication) throws IOException {
transactionsService.uploadMultipleTransactionsFromExcelSheetForUser(
file.getInputStream(),
authentication.getName()
);
return ResponseEntity.ofNullable(ok(1));
}
}

View file

@ -0,0 +1,9 @@
package dev.mvvasilev.finances.dtos;
import dev.mvvasilev.finances.enums.RawTransactionValueType;
public record TransactionValueGroupDTO(
Long id,
String name,
RawTransactionValueType type
) {}

View file

@ -0,0 +1,9 @@
package dev.mvvasilev.finances.dtos;
import java.time.LocalDateTime;
public record UploadedStatementDTO (
Long id,
String name,
LocalDateTime timeUploaded
) {}

View file

@ -1,26 +0,0 @@
package dev.mvvasilev.finances.entity;
public class Account {
private String name;
private long userId;
public Account () {}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getUserId() {
return userId;
}
public void setUserId(long userId) {
this.userId = userId;
}
}

View file

@ -1,41 +0,0 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.finances.enums.TransactionType;
import java.math.BigDecimal;
public class AccountTransaction {
private BigDecimal amount;
private TransactionType type;
private String reason;
public AccountTransaction() {
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public TransactionType getType() {
return type;
}
public void setType(TransactionType type) {
this.type = type;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}

View file

@ -0,0 +1,85 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(schema = "transactions")
public class ProcessedTransaction extends AbstractEntity {
private String description;
private Integer userId;
private Double amount;
private boolean isInflow;
private Long categoryId;
private LocalDateTime timestamp;
// private Long transactionMappingId;
public ProcessedTransaction() {
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Double getAmount() {
return amount;
}
public void setAmount(Double amount) {
this.amount = amount;
}
public boolean isInflow() {
return isInflow;
}
public void setInflow(boolean inflow) {
isInflow = inflow;
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
// public Long getTransactionMappingId() {
// return transactionMappingId;
// }
//
// public void setTransactionMappingId(Long transactionMappingId) {
// this.transactionMappingId = transactionMappingId;
// }
}

View file

@ -10,6 +10,8 @@ public class RawStatement extends AbstractEntity {
private Integer userId;
private String name;
public RawStatement() {
}
@ -20,4 +22,12 @@ public class RawStatement extends AbstractEntity {
public void setUserId(Integer userId) {
this.userId = userId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View file

@ -19,6 +19,8 @@ public class RawTransactionValue extends AbstractEntity {
private Boolean booleanValue;
private int rowIndex;
public RawTransactionValue() {
}
@ -61,4 +63,12 @@ public class RawTransactionValue extends AbstractEntity {
public void setBooleanValue(Boolean booleanValue) {
this.booleanValue = booleanValue;
}
public int getRowIndex() {
return rowIndex;
}
public void setRowIndex(int rowIndex) {
this.rowIndex = rowIndex;
}
}

View file

@ -0,0 +1,33 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(schema = "categories")
public class TransactionCategory extends AbstractEntity {
private Integer userId;
private String name;
public TransactionCategory() {
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View file

@ -0,0 +1,15 @@
package dev.mvvasilev.finances.entity;
import dev.mvvasilev.common.data.AbstractEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
//@Entity
//@Table(schema = "mappings")
//public class TransactionMapping extends AbstractEntity {
//
// private Long rawStatementId;
//
//
//
//}

View file

@ -1,7 +0,0 @@
package dev.mvvasilev.finances.enums;
public enum TransactionCategory {
Food,
Shopping,
Miscellaneous
}

View file

@ -1,8 +0,0 @@
package dev.mvvasilev.finances.enums;
public enum TransactionType {
INFLOW,
OUTFLOW
}

View file

@ -1,9 +1,27 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.finances.entity.RawStatement;
import dev.mvvasilev.finances.persistence.dtos.RawStatementDTO;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
@Repository
public interface RawStatementRepository extends JpaRepository<RawStatement, Long> {
@Query(value =
"SELECT " +
"id, " +
"time_created as TimeCreated, " +
"time_last_modified as TimeLastModified, " +
"user_id as UserId, name " +
"FROM transactions.raw_statement " +
"WHERE user_id = :userId",
nativeQuery = true
)
Collection<RawStatementDTO> fetchAllForUser(@Param("userId") int userId);
}

View file

@ -1,9 +1,27 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.finances.entity.RawTransactionValueGroup;
import dev.mvvasilev.finances.persistence.dtos.RawTransactionValueGroupDTO;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
@Repository
public interface RawTransactionValueGroupRepository extends JpaRepository<RawTransactionValueGroup, Long> {
@Query(value =
"SELECT " +
"rtvg.id, " +
"rtvg.name, " +
"rtvg.type " +
"FROM transactions.raw_transaction_value_group AS rtvg " +
"JOIN transactions.raw_statement AS rs ON rtvg.statement_id = rs.id " +
"WHERE rs.user_id = :userId AND rs.id = :statementId",
nativeQuery = true
)
Collection<RawTransactionValueGroupDTO> fetchAllForStatementAndUser(@Param("statementId") Long statementId, @Param("userId") Integer userId);
}

View file

@ -0,0 +1,13 @@
package dev.mvvasilev.finances.persistence.dtos;
import java.time.LocalDateTime;
public interface RawStatementDTO {
Long getId();
String getName();
LocalDateTime getTimeCreated();
}

View file

@ -0,0 +1,13 @@
package dev.mvvasilev.finances.persistence.dtos;
import dev.mvvasilev.finances.enums.RawTransactionValueType;
public interface RawTransactionValueGroupDTO {
Long getId();
String getName();
int getType();
}

View file

@ -1,5 +1,7 @@
package dev.mvvasilev.finances.services;
import dev.mvvasilev.finances.dtos.TransactionValueGroupDTO;
import dev.mvvasilev.finances.dtos.UploadedStatementDTO;
import dev.mvvasilev.finances.entity.RawStatement;
import dev.mvvasilev.finances.entity.RawTransactionValue;
import dev.mvvasilev.finances.entity.RawTransactionValueGroup;
@ -8,23 +10,14 @@ import dev.mvvasilev.finances.persistence.RawStatementRepository;
import dev.mvvasilev.finances.persistence.RawTransactionValueGroupRepository;
import dev.mvvasilev.finances.persistence.RawTransactionValueRepository;
import jakarta.transaction.Transactional;
import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellUtil;
import org.hibernate.type.descriptor.DateTimeUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
@ -32,13 +25,14 @@ import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
@Service
@Transactional
public class TransactionsService {
public class StatementsService {
private static final DateTimeFormatter DATE_FORMAT = new DateTimeFormatterBuilder()
.appendPattern("dd.MM.yyyy[ [HH][:mm][:ss]]")
@ -55,14 +49,15 @@ public class TransactionsService {
private RawTransactionValueRepository rawTransactionValueRepository;
@Autowired
public TransactionsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository) {
public StatementsService(RawStatementRepository rawStatementRepository, RawTransactionValueGroupRepository rawTransactionValueGroupRepository, RawTransactionValueRepository rawTransactionValueRepository) {
this.rawStatementRepository = rawStatementRepository;
this.rawTransactionValueGroupRepository = rawTransactionValueGroupRepository;
this.rawTransactionValueRepository = rawTransactionValueRepository;
}
public void uploadMultipleTransactionsFromExcelSheetForUser(InputStream workbookInputStream, String userId) throws IOException {
public void uploadStatementFromExcelSheetForUser(String fileName, String mimeType, InputStream workbookInputStream, int userId) throws IOException {
var workbook = WorkbookFactory.create(workbookInputStream);
var firstWorksheet = workbook.getSheetAt(0);
@ -70,7 +65,8 @@ public class TransactionsService {
var lastRowIndex = firstWorksheet.getLastRowNum();
var statement = new RawStatement();
statement.setUserId(Integer.parseInt(userId));
statement.setUserId(userId);
statement.setName(fileName);
statement = rawStatementRepository.saveAndFlush(statement);
@ -78,6 +74,7 @@ public class TransactionsService {
List<RawTransactionValueGroup> valueGroups = new ArrayList<>();
// turn each column into a value group
for (var c : firstRow) {
if (c == null || c.getCellType() == CellType.BLANK) {
@ -92,9 +89,11 @@ public class TransactionsService {
// group type is string by default, if no other type could have been determined
var groupType = RawTransactionValueType.STRING;
// iterate down through the rows on this column, looking for the first one to return a type
for (int y = c.getRowIndex() + 1; y <= lastRowIndex; y++) {
var typeResult = determineGroupType(firstWorksheet, y, c.getColumnIndex());
// if a type has been determined, stop here
if (typeResult.isPresent()) {
groupType = typeResult.get();
break;
@ -109,6 +108,8 @@ public class TransactionsService {
valueGroups = rawTransactionValueGroupRepository.saveAllAndFlush(valueGroups);
var column = 0;
// turn each cell in each row into a value, related to the value group ( column )
for (var group : valueGroups) {
var valueList = new ArrayList<RawTransactionValue>();
@ -116,6 +117,7 @@ public class TransactionsService {
var value = new RawTransactionValue();
value.setGroupId(group.getId());
value.setRowIndex(y);
switch (group.getType()) {
case STRING -> value.setStringValue(firstWorksheet.getRow(y).getCell(column).getStringCellValue());
@ -157,13 +159,31 @@ public class TransactionsService {
return Optional.empty();
}
private boolean isValidDate(String inDate) {
private boolean isValidDate(String stringDate) {
try {
DATE_FORMAT.parse(inDate.trim());
DATE_FORMAT.parse(stringDate);
} catch (DateTimeParseException e) {
return false;
}
return true;
}
public Collection<UploadedStatementDTO> fetchStatementsForUser(int userId) {
return rawStatementRepository.fetchAllForUser(userId)
.stream()
.map(dto -> new UploadedStatementDTO(dto.getId(), dto.getName(), dto.getTimeCreated()))
.collect(Collectors.toList());
}
public Collection<TransactionValueGroupDTO> fetchTransactionValueGroupsForUserStatement(Long statementId, int userId) {
return rawTransactionValueGroupRepository.fetchAllForStatementAndUser(statementId, userId)
.stream()
.map(dto -> new TransactionValueGroupDTO(dto.getId(), dto.getName(), RawTransactionValueType.values()[dto.getType()]))
.collect(Collectors.toList());
}
public void deleteStatement(Long statementId, int userId) {
rawStatementRepository.deleteById(statementId);
rawStatementRepository.flush();
}
}

View file

@ -0,0 +1,9 @@
CREATE SCHEMA IF NOT EXISTS categories;
CREATE TABLE IF NOT EXISTS categories.transaction_category (
id BIGSERIAL,
name VARCHAR(255),
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_transaction_category PRIMARY KEY (id)
)

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS transactions.processed_transaction (
id BIGSERIAL,
description VARCHAR(1024),
amount FLOAT,
is_inflow BOOLEAN,
category_id BIGINT,
timestamp TIMESTAMP,
time_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
time_last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT PK_processed_transaction PRIMARY KEY (id)
);

View file

@ -0,0 +1,3 @@
ALTER TABLE transactions.processed_transaction ADD user_id INTEGER;
ALTER TABLE categories.transaction_category ADD user_id INTEGER;

View file

@ -0,0 +1 @@
ALTER TABLE transactions.raw_transaction_value ADD row_index INTEGER NOT NULL;

View file

@ -0,0 +1 @@
ALTER TABLE transactions.raw_statement ADD name VARCHAR(255);

View file

@ -0,0 +1,11 @@
ALTER TABLE transactions.raw_transaction_value
DROP CONSTRAINT fk_raw_transaction_value_raw_transaction_value_group;
ALTER TABLE transactions.raw_transaction_value
ADD CONSTRAINT FK_raw_transaction_value_raw_transaction_value_group FOREIGN KEY (group_id) REFERENCES transactions.raw_transaction_value_group(id) ON DELETE CASCADE;
ALTER TABLE transactions.raw_transaction_value_group
DROP CONSTRAINT fk_raw_transaction_value_group_raw_statement;
ALTER TABLE transactions.raw_transaction_value_group
ADD CONSTRAINT FK_raw_transaction_value_group_raw_statement FOREIGN KEY (statement_id) REFERENCES transactions.raw_statement(id) ON DELETE CASCADE;

View file

@ -17,6 +17,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-material-ui-carousel": "^3.4.2",
"react-router-dom": "^6.21.0",
"vis-data": "^7.1.9",
"vis-network": "^9.1.9"
@ -2722,6 +2723,48 @@
"is-callable": "^1.1.3"
}
},
"node_modules/framer-motion": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-4.1.17.tgz",
"integrity": "sha512-thx1wvKzblzbs0XaK2X0G1JuwIdARcoNOW7VVwjO8BUltzXPyONGAElLu6CiCScsOQRI7FIk/45YTFtJw5Yozw==",
"dependencies": {
"framesync": "5.3.0",
"hey-listen": "^1.0.8",
"popmotion": "9.3.6",
"style-value-types": "4.1.4",
"tslib": "^2.1.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"react": ">=16.8 || ^17.0.0",
"react-dom": ">=16.8 || ^17.0.0"
}
},
"node_modules/framer-motion/node_modules/@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
},
"node_modules/framer-motion/node_modules/@emotion/memoize": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/framesync": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz",
"integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -2978,6 +3021,11 @@
"node": ">= 0.4"
}
},
"node_modules/hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -3819,6 +3867,17 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true
},
"node_modules/popmotion": {
"version": "9.3.6",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.3.6.tgz",
"integrity": "sha512-ZTbXiu6zIggXzIliMi8LGxXBF5ST+wkpXGEjeTUDUOCdSQ356hij/xjeUdv0F8zCQNeqB1+PR5/BB+gC+QLAPw==",
"dependencies": {
"framesync": "5.3.0",
"hey-listen": "^1.0.8",
"style-value-types": "4.1.4",
"tslib": "^2.1.0"
}
},
"node_modules/postcss": {
"version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
@ -3938,6 +3997,28 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-material-ui-carousel": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/react-material-ui-carousel/-/react-material-ui-carousel-3.4.2.tgz",
"integrity": "sha512-jUbC5aBWqbbbUOOdUe3zTVf4kMiZFwKJqwhxzHgBfklaXQbSopis4iWAHvEOLcZtSIJk4JAGxKE0CmxDoxvUuw==",
"dependencies": {
"@emotion/react": "^11.7.1",
"@emotion/styled": "^11.6.0",
"@mui/icons-material": "^5.4.1",
"@mui/material": "^5.4.1",
"@mui/system": "^5.4.1",
"framer-motion": "^4.1.17"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@mui/icons-material": "^5.0.0",
"@mui/material": "^5.0.0",
"@mui/system": "^5.0.0",
"react": "^17.0.1 || ^18.0.0",
"react-dom": "^17.0.2 || ^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
@ -4354,6 +4435,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-value-types": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz",
"integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==",
"dependencies": {
"hey-listen": "^1.0.8",
"tslib": "^2.1.0"
}
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
@ -4395,6 +4485,11 @@
"node": ">=4"
}
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -19,6 +19,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-material-ui-carousel": "^3.4.2",
"react-router-dom": "^6.21.0",
"vis-data": "^7.1.9",
"vis-network": "^9.1.9"

View file

@ -1,7 +1,7 @@
import { Routes, Route } from 'react-router-dom';
import HomePage from "@/app/pages/HomePage"
import RootLayout from '@/app/Layout';
import TransactionsPage from './app/pages/TransactionsPage';
import StatementsPage from './app/pages/StatementsPage.jsx';
function App() {
return (
@ -9,7 +9,7 @@ function App() {
<RootLayout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/statements" element={<StatementsPage />} />
</Routes>
</RootLayout>
</>

View file

@ -1,7 +1,7 @@
import * as React from 'react';
import {Link} from 'react-router-dom';
import CssBaseline from '@mui/material/CssBaseline'
import {ThemeProvider, createTheme} from '@mui/material';
import {ThemeProvider, createTheme, Container, Backdrop} from '@mui/material';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import Divider from '@mui/material/Divider';
@ -17,12 +17,13 @@ import {Login as LoginIcon} from "@mui/icons-material";
import {Toaster} from 'react-hot-toast';
import theme from '../components/ThemeRegistry/theme';
import Button from "@mui/material/Button";
import Grid from "@mui/material/Unstable_Grid2";
const DRAWER_WIDTH = 240;
const NAV_LINKS = [
{text: 'Home', to: '/', icon: HomeIcon},
{text: 'Transactions', to: '/transactions', icon: TransactionsIcon},
{text: 'Statements', to: '/statements', icon: TransactionsIcon},
];
const BOTTOM_LINKS = [
@ -49,6 +50,7 @@ export default function RootLayout({children}) {
return (
<ThemeProvider theme={theme}>
<CssBaseline/>
<Drawer
sx={{
width: DRAWER_WIDTH,
@ -112,12 +114,14 @@ export default function RootLayout({children}) {
top: 0,
flexGrow: 1,
bgcolor: 'background.default',
ml: `${DRAWER_WIDTH}px`,
left: `${DRAWER_WIDTH}px`,
right: 0,
p: 3,
}}
>
{children}
</Box>
<Toaster
toastOptions={{
success: {
@ -132,6 +136,6 @@ export default function RootLayout({children}) {
},
}}
/>
</ ThemeProvider>
</ThemeProvider>
);
}

View file

@ -1,7 +1,5 @@
import * as React from 'react';
import Grid from '@mui/material/Unstable_Grid2';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import MediaCard from '@/components/MediaCard';
import { Stack } from '@mui/material';
@ -9,10 +7,6 @@ export default function HomePage() {
return (
<Stack>
<div>
{/* <Alert severity="info" sx={{ mt: 2, mb: 5 }}>
<AlertTitle>Hello</AlertTitle>
This app uses React Router and Material UI v5.
</Alert> */}
<Grid container rowSpacing={3} columnSpacing={3}>
<Grid xs={4}>
<MediaCard
@ -32,108 +26,6 @@ export default function HomePage() {
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 xs={4}>
<MediaCard
heading="CIELAB"
text="The CIELAB color space, also referred to as L*a*b*, was intended as a perceptually uniform space, where a given numerical change corresponds to a similar perceived change in color."
/>
</Grid>
<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."
/>
</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 xs={4}>
<MediaCard
heading="CIELAB"
text="The CIELAB color space, also referred to as L*a*b*, was intended as a perceptually uniform space, where a given numerical change corresponds to a similar perceived change in color."
/>
</Grid>
<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."
/>
</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 xs={4}>
<MediaCard
heading="CIELAB"
text="The CIELAB color space, also referred to as L*a*b*, was intended as a perceptually uniform space, where a given numerical change corresponds to a similar perceived change in color."
/>
</Grid>
<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."
/>
</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 xs={4}>
<MediaCard
heading="CIELAB"
text="The CIELAB color space, also referred to as L*a*b*, was intended as a perceptually uniform space, where a given numerical change corresponds to a similar perceived change in color."
/>
</Grid>
<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."
/>
</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 xs={4}>
<MediaCard
heading="CIELAB"
text="The CIELAB color space, also referred to as L*a*b*, was intended as a perceptually uniform space, where a given numerical change corresponds to a similar perceived change in color."
/>
</Grid>
</Grid>
</div>
</Stack>

View file

@ -0,0 +1,156 @@
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import { CloudUpload as CloudUploadIcon } from "@mui/icons-material";
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
import toast from "react-hot-toast";
import utils from "@/utils.js";
import Grid from "@mui/material/Unstable_Grid2";
import {useEffect, useState} from "react";
import {Stack} from "@mui/material";
import VisNetwork from "@/components/statements/VisNetwork.jsx";
import StatementCard from "@/components/statements/StatementCard.jsx";
import Carousel from "react-material-ui-carousel";
import NodeModal from "@/components/statements/NodeModal.jsx";
import StatementMappingEditor from "@/components/statements/StatementMappingEditor.jsx";
export default function StatementsPage() {
const [statements, setStatements] = useState([]);
const [valueGroups, setValueGroups] = useState([]);
useEffect(() => {
utils.performRequest("/api/statements")
.then(resp => resp.json())
.then(({ result }) => setStatements(result));
}, []);
function fetchStatements() {
utils.performRequest("/api/statements")
.then(resp => resp.json())
.then(({ result }) => setStatements(result));
}
async function uploadStatement({ target }) {
let file = target.files[0];
let formData = new FormData();
formData.append("file", file);
await toast.promise(
utils.performRequest("/api/statements/uploadSheet", {
method: "POST",
body: formData
}),
{
loading: "Uploading...",
success: () => {
fetchStatements();
return "Upload successful!";
},
error: (err) => `Uh oh, something went wrong: ${err}`
}
);
}
async function mapStatement(e, statementId) {
await toast.promise(
utils.performRequest(`/api/statements/${statementId}/transactionValueGroups`)
.then(res => res.json())
.then(json => setValueGroups(json.result)),
{
loading: "Preparing...",
success: "Ready",
error: (err) => `Uh oh, something went wrong: ${err}`
}
);
}
async function deleteStatement(e, statementId) {
await toast.promise(
utils.performRequest(`/api/statements/${statementId}`, { method: "DELETE" }),
{
loading: "Deleting...",
success: () => {
fetchStatements();
return "Success";
},
error: (err) => `Uh oh, something went wrong: ${err}`
}
)
}
function createCarouselItems() {
let carouselItemCount = Math.ceil(statements.length / 4) || 1;
let carouselItems = [];
for (let i = 0; i < carouselItemCount; i++) {
let firstIndex = i * 4;
let lastIndex = firstIndex + 4;
if (lastIndex > statements.length) {
lastIndex = statements.length;
}
carouselItems.push(
<Grid key={i} container spacing={2}>
{
statements.slice(firstIndex, lastIndex).map((statement) => (
<Grid key={statement.id} xs={3}>
<StatementCard
id={statement.id}
timeUploaded={statement.timeUploaded}
name={statement.name}
onMap={mapStatement}
onDelete={deleteStatement}
/>
</Grid>
))
}
</Grid>
);
}
return carouselItems;
}
var i = 0;
return (
<Stack>
<div>
<Grid container rowSpacing={3} columnSpacing={3}>
<Grid xs={3}>
<Button component="label" variant="contained" startIcon={<CloudUploadIcon />}>
Upload Statement
<VisuallyHiddenInput type="file" onChange={uploadStatement} />
</Button>
</Grid>
<Grid xs={9}></Grid>
<Grid xs={12}>
<Carousel
cycleNavigation
fullHeightHover
swipe
animation={"slide"}
duration={100}
autoPlay={false}
>
{createCarouselItems()}
</Carousel>
</Grid>
<Grid xs={12}>
<StatementMappingEditor valueGroups={valueGroups} />
</Grid>
</Grid>
</div>
</Stack>
);
}

View file

@ -1,19 +0,0 @@
import Button from "@mui/material/Button";
import { Publish as ImportExportIcon } from "@mui/icons-material";
export default function TransactionsPage() {
return (
<>
<Button variant="contained" onClick={() => {
fetch("/api/user-info", {
method: "POST"
})
.then((resp) => {
console.log(resp)
});
}}>
<ImportExportIcon /> Import
</Button>
</>
);
}

View file

@ -1,16 +0,0 @@
import { useRef, useEffect } from "react";
import { Network } from "vis-network";
export default function VisNetwork(props) {
const visJsRef = useRef(null);
const nodes = props.nodes || [];
const edges = props.edges || [];
const options = props.options || {};
useEffect(() => {
const network = visJsRef.current && new Network(visJsRef.current, { nodes, edges }, options);
}, [visJsRef, nodes, edges]);
return <div ref={visJsRef} />;
}

View file

@ -0,0 +1,15 @@
import {styled} from "@mui/material";
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export default VisuallyHiddenInput;

View file

@ -0,0 +1,30 @@
import {Modal} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
export default function NodeModal({ open }) {
return (
<Modal open={open}>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
pt: 2,
px: 4,
pb: 3,
width: "400px"
}}
>
<h2 id="child-modal-title">Text in a child modal</h2>
<p id="child-modal-description">
Lorem ipsum, dolor sit amet consectetur adipisicing elit.
</p>
<Button>Close Child Modal</Button>
</Box>
</Modal>
);
}

View file

@ -0,0 +1,29 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import * as React from "react";
import CardActions from "@mui/material/CardActions";
import Button from "@mui/material/Button";
import { AccountTree as AccountTreeIcon } from "@mui/icons-material";
import { Delete as DeleteIcon } from "@mui/icons-material";
import Grid from "@mui/material/Unstable_Grid2";
export default function StatementCard({ name, timeUploaded, id, onMap, onDelete }) {
return (
<Card id={`statement-${id}`}>
<CardContent>
<Typography gutterBottom>
{name} uploaded on {new Date(timeUploaded).toLocaleString("en-GB")}
</Typography>
</CardContent>
<CardActions>
<Button variant="contained" size="small" onClick={(e) => onMap(e, id)} startIcon={<AccountTreeIcon />}>
Map
</Button>
<Button variant="contained" size="small" onClick={(e) => onDelete(e, id)} startIcon={<DeleteIcon />}>
Delete
</Button>
</CardActions>
</Card>
);
}

View file

@ -0,0 +1,42 @@
import VisNetwork from "@/components/statements/VisNetwork.jsx";
import Grid from "@mui/material/Unstable_Grid2";
import Button from "@mui/material/Button";
import NodeModal from "@/components/statements/NodeModal.jsx";
export default function StatementMappingEditor({ shown: shown = false, valueGroups: valueGroups = [] }) {
var i = 0;
return (
<Grid justifyContent={"space-between"} container columnSpacing={3}>
<Grid xs={9}>
<VisNetwork
nodes={valueGroups.map(group => {
return {
id: group.id,
label: group.name,
x: 0, y: (i++) * 10
}
})}
backgroundColor="#222222"
options={{
height: "1000px"
}}
/>
</Grid>
<Grid xs={3} container columnSpacing={1}>
<Grid xs={6}>
<Button>Add Node</Button>
</Grid>
<Grid xs={6}>
<Button>Add Connection</Button>
</Grid>
</Grid>
{/*<NodeModal open={true}></NodeModal>*/}
</Grid>
);
}

View file

@ -0,0 +1,29 @@
import {useRef, useEffect, useState} from "react";
import { Network } from "vis-network";
export default function VisNetwork({ nodes: nodes = [], edges: edges = [], backgroundColor: backgroundColor = "#ffffff", options: options = {} }) {
const visJsRef = useRef(null);
const [network, setNetwork] = useState();
useEffect(() => {
const network = visJsRef.current && new Network(visJsRef.current, { nodes, edges }, options);
network.on("beforeDrawing", function(ctx) {
// save current translate/zoom
ctx.save();
// reset transform to identity
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
// restore old transform
ctx.restore();
})
}, [visJsRef, backgroundColor, edges, nodes, options]);
return <div ref={visJsRef} />;
}

View file

@ -5,9 +5,7 @@ import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

19
frontend/src/utils.js Normal file
View file

@ -0,0 +1,19 @@
let utils = {
performRequest: async (url, options) => {
return await fetch(url, options).then(resp => {
if (resp.status === 401) {
window.location.replace("https://localhost:8080/oauth2/authorization/authentik")
throw "Unauthorized, please login.";
}
if (!resp.ok) {
throw resp.status;
}
return resp;
});
}
}
export default utils;