mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-19 14:19:52 +03:00
#2: Display categories on transactions page
This commit is contained in:
parent
94daac1600
commit
76a658986b
12 changed files with 215 additions and 51 deletions
4
.idea/dataSources.xml
generated
4
.idea/dataSources.xml
generated
|
@ -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
2
.idea/misc.xml
generated
|
@ -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>
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
61
frontend/src/components/categories/CategoriesBox.jsx
Normal file
61
frontend/src/components/categories/CategoriesBox.jsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue