#2: Display categories on transactions page

This commit is contained in:
Miroslav Vasilev 2023-12-30 18:30:25 +02:00
parent 94daac1600
commit 76a658986b
12 changed files with 215 additions and 51 deletions

4
.idea/dataSources.xml generated
View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="finances@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
<data-source source="LOCAL" name="@localhost" uuid="fa2f05d4-8222-487a-b3c3-3e6fa0c7164c">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/finances</jdbc-url>
<jdbc-url>jdbc:postgresql://localhost:5432/</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />

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" project-jdk-name="21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="openjdk-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View file

@ -41,11 +41,11 @@ spring:
- Path=/**
server:
ssl:
enabled: true
key-store-type: PKCS12
key-store: classpath:keystore/local.p12
key-store-password: asdf1234
key-alias: local
enabled: ${SSL_ENABLED}
key-store-type: ${SSL_KEY_STORE_TYPE}
key-store: ${SSL_KEY_STORE}
key-store-password: ${SSL_KEY_STORE_PASSWORD}
key-alias: ${SSL_KEY_ALIAS}
reactive:
session:
cookie:

View file

@ -1,6 +1,9 @@
package dev.mvvasilev.finances.persistence;
import dev.mvvasilev.finances.dtos.TransactionCategoryDTO;
import dev.mvvasilev.finances.entity.ProcessedTransaction;
import dev.mvvasilev.finances.entity.ProcessedTransactionCategory;
import dev.mvvasilev.finances.entity.TransactionCategory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

View file

@ -28,4 +28,14 @@ public interface TransactionCategoryRepository extends JpaRepository<Transaction
@Param("name") String name,
@Param("ruleBehavior") CategorizationRuleBehavior ruleBehavior
);
@Query(value = """
SELECT tc.*
FROM categories.processed_transaction_category AS ptc
JOIN categories.transaction_category AS tc ON tc.id = ptc.category_id
WHERE ptc.processed_transaction_id = :transactionId
""",
nativeQuery = true
)
Collection<TransactionCategory> fetchCategoriesForTransaction(@Param("transactionId") Long transactionId);
}

View file

@ -1,8 +1,10 @@
package dev.mvvasilev.finances.services;
import dev.mvvasilev.finances.dtos.ProcessedTransactionDTO;
import dev.mvvasilev.finances.dtos.TransactionCategoryDTO;
import dev.mvvasilev.finances.entity.ProcessedTransaction;
import dev.mvvasilev.finances.persistence.ProcessedTransactionRepository;
import dev.mvvasilev.finances.persistence.TransactionCategoryRepository;
import org.apache.commons.compress.utils.Lists;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.*;
@ -15,9 +17,12 @@ public class ProcessedTransactionService {
final private ProcessedTransactionRepository processedTransactionRepository;
final private TransactionCategoryRepository transactionCategoryRepository;
@Autowired
public ProcessedTransactionService(ProcessedTransactionRepository processedTransactionRepository) {
public ProcessedTransactionService(ProcessedTransactionRepository processedTransactionRepository, TransactionCategoryRepository transactionCategoryRepository) {
this.processedTransactionRepository = processedTransactionRepository;
this.transactionCategoryRepository = transactionCategoryRepository;
}
public Page<ProcessedTransactionDTO> fetchPagedProcessedTransactionsForUser(int userId, final Pageable pageable) {
@ -28,7 +33,9 @@ public class ProcessedTransactionService {
t.isInflow(),
t.getTimestamp(),
t.getDescription(),
Lists.newArrayList() // TODO: Fetch categories. Do it all in SQL for better performance.
transactionCategoryRepository.fetchCategoriesForTransaction(t.getId())
.stream().map(ptc -> new TransactionCategoryDTO(ptc.getId(), ptc.getName()))
.toList()
));
}
}

View file

@ -20,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
@ -119,6 +120,7 @@ public class StatementsService {
// turn each cell in each row into a value, related to the value group ( column )
for (var group : valueGroups) {
var groupType = group.getType();
var valueList = new ArrayList<RawTransactionValue>();
for (int y = 1; y < lastRowIndex; y++) {
@ -127,14 +129,34 @@ public class StatementsService {
value.setGroupId(group.getId());
value.setRowIndex(y);
switch (group.getType()) {
case STRING -> value.setStringValue(firstWorksheet.getRow(y).getCell(column).getStringCellValue());
case NUMERIC ->
value.setNumericValue(firstWorksheet.getRow(y).getCell(column).getNumericCellValue());
case TIMESTAMP ->
value.setTimestampValue(LocalDateTime.parse(firstWorksheet.getRow(y).getCell(column).getStringCellValue().trim(), DATE_FORMAT));
case BOOLEAN ->
value.setBooleanValue(firstWorksheet.getRow(y).getCell(column).getBooleanCellValue());
try {
var cellValue = firstWorksheet.getRow(y).getCell(column).getStringCellValue().trim();
try {
switch (groupType) {
case STRING -> value.setStringValue(cellValue);
case NUMERIC -> value.setNumericValue(Double.parseDouble(cellValue));
case TIMESTAMP -> value.setTimestampValue(LocalDateTime.parse(cellValue, DATE_FORMAT));
case BOOLEAN -> value.setBooleanValue(Boolean.parseBoolean(cellValue));
}
} catch (Exception e) {
switch (groupType) {
case STRING -> value.setStringValue("");
case NUMERIC -> value.setNumericValue(0.0);
case TIMESTAMP -> value.setTimestampValue(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC));
case BOOLEAN -> value.setBooleanValue(false);
}
}
} catch (IllegalStateException e) {
// Cell was numeric
var cellValue = firstWorksheet.getRow(y).getCell(column).getNumericCellValue();
switch (groupType) {
case STRING -> value.setStringValue(Double.toString(cellValue));
case NUMERIC -> value.setNumericValue(cellValue);
case TIMESTAMP -> value.setTimestampValue(LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC));
case BOOLEAN -> value.setBooleanValue(false);
}
}
valueList.add(value);

View file

@ -15,6 +15,7 @@
"@mui/material": "^5.15.0",
"@mui/x-data-grid": "^6.18.6",
"@mui/x-date-pickers": "^6.18.6",
"@mui/x-tree-view": "^6.17.0",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"react": "^18.2.0",
@ -1440,6 +1441,35 @@
}
}
},
"node_modules/@mui/x-tree-view": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-6.17.0.tgz",
"integrity": "sha512-09dc2D+Rjg2z8KOaxbUXyPi0aw7fm2jurEtV8Xw48xJ00joLWd5QJm1/v4CarEvaiyhTQzHImNqdgeJW8ZQB6g==",
"dependencies": {
"@babel/runtime": "^7.23.2",
"@mui/base": "^5.0.0-beta.20",
"@mui/utils": "^5.14.14",
"@types/react-transition-group": "^4.4.8",
"clsx": "^2.0.0",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.8.6",
"@mui/system": "^5.8.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View file

@ -17,6 +17,7 @@
"@mui/material": "^5.15.0",
"@mui/x-data-grid": "^6.18.6",
"@mui/x-date-pickers": "^6.18.6",
"@mui/x-tree-view": "^6.17.0",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"react": "^18.2.0",

View file

@ -1,6 +1,4 @@
import {TreeItem, TreeView} from "@mui/x-tree-view";
import {
Delete,
Category as CategoryIcon,
Add as AddIcon,
Close as CloseIcon,
@ -25,6 +23,7 @@ import {
} from "@mui/material";
import Box from "@mui/material/Box";
import CategorizationRulesEditor from "@/components/categories/CategorizationRulesEditor.jsx";
import CategoriesBox from "@/components/categories/CategoriesBox.jsx";
export default function CategoriesPage() {
@ -176,39 +175,52 @@ export default function CategoriesPage() {
</Grid>
<Grid xs={12} lg={12}>
<Stack
sx={{
overflowY: "scroll"
}}
<CategoriesBox
categories={categories}
minHeight={"100px"}
maxHeight={"250px"}
useFlexGap
flexWrap="wrap"
direction={"row"}
spacing={1}
>
{
categories.map(c => {
let variant = (selectedCategory?.id ?? -1) === c.id ? "filled" : "outlined";
selectable
selected={selectedCategory}
onCategorySelect={(e, c) => setSelectedCategory({...c})}
onCategoryDelete={(e, c) => {
setSelectedCategory(c);
openConfirmDeleteCategoryModal(true);
}}
showDelete
/>
{/*<Stack*/}
{/* sx={{*/}
{/* overflowY: "scroll"*/}
{/* }}*/}
{/* minHeight={"100px"}*/}
{/* maxHeight={"250px"}*/}
{/* useFlexGap*/}
{/* flexWrap="wrap"*/}
{/* direction={"row"}*/}
{/* spacing={1}*/}
{/*>*/}
{/* {*/}
{/* categories.map(c => {*/}
{/* let variant = (selectedCategory?.id ?? -1) === c.id ? "filled" : "outlined";*/}
return (
<Chip
key={c.id}
onClick={(e) => {
setSelectedCategory({...c});
}}
onDelete={() => {
setSelectedCategory(c);
openConfirmDeleteCategoryModal(true);
}}
label={c.name}
deleteIcon={<Delete/>}
variant={variant}
/>
);
})
}
</Stack>
{/* return (*/}
{/* <Chip*/}
{/* key={c.id}*/}
{/* onClick={(e) => {*/}
{/* setSelectedCategory({...c});*/}
{/* }}*/}
{/* onDelete={() => {*/}
{/* setSelectedCategory(c);*/}
{/* openConfirmDeleteCategoryModal(true);*/}
{/* }}*/}
{/* label={c.name}*/}
{/* deleteIcon={<Delete/>}*/}
{/* variant={variant}*/}
{/* />*/}
{/* );*/}
{/* })*/}
{/* }*/}
{/*</Stack>*/}
</Grid>
<Grid xs={12} lg={12}>

View file

@ -4,6 +4,7 @@ import {useEffect, useState} from "react";
import {DataGrid} from "@mui/x-data-grid";
import utils from "@/utils.js";
import {ArrowDownward, ArrowUpward, PriceChange} from "@mui/icons-material";
import CategoriesBox from "@/components/categories/CategoriesBox.jsx";
const COLUMNS = [
{
@ -56,6 +57,23 @@ const COLUMNS = [
filterable: false,
valueFormatter: val => new Date(val.value).toLocaleString("bg-BG")
},
{
field: "categories",
headerName: "Categories",
maxWidth: 300,
flex: true,
sortable: false,
filterable: false,
renderCell: (params) => {
return (
<CategoriesBox
categories={params.value}
minHeight={0}
selectable={false}
/>
);
}
}
];
export default function TransactionsPage() {
@ -99,7 +117,7 @@ export default function TransactionsPage() {
>
<Grid
sx={{
height: "1200px"
height: "95vh"
}}
xs={12}
lg={12}

View file

@ -0,0 +1,61 @@
import {Chip, Stack} from "@mui/material";
import {Delete} from "@mui/icons-material";
export default function CategoriesBox({
categories: categories = [],
selectable: selectable = false,
selected: selected = {},
onCategorySelect: onCategorySelect = (event, category) => {},
onCategoryDelete: onCategoryDelete = undefined,
showDelete: showDelete = false,
sx: sx = {},
minHeight: minHeight = "100px",
maxHeight: maxHeight = "250px",
}) {
return (
<Stack
sx={{
overflowY: "scroll",
...sx
}}
minHeight={minHeight ?? "100px"}
maxHeight={maxHeight ?? "250px"}
useFlexGap
flexWrap="wrap"
direction={"row"}
spacing={1}
>
{
categories.map(c => {
let variant = selectable && (selected?.id ?? -1) === c.id ? "filled" : "outlined";
let isDeletable = onCategoryDelete !== undefined;
return (
<>
{
isDeletable &&
<Chip
key={c.id}
onClick={(e) => onCategorySelect(e, c)}
onDelete={(e) => onCategoryDelete(e, c)}
label={c.name}
deleteIcon={showDelete === true ? <Delete/> : ""}
variant={variant}
/>
}
{
!isDeletable &&
<Chip
key={c.id}
onClick={(e) => onCategorySelect(e, c)}
label={c.name}
variant={variant}
/>
}
</>
);
})
}
</Stack>
);
}