mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-18 21:59:52 +03:00
Added Sum Widget
This commit is contained in:
parent
3ec459da6f
commit
51465ad44d
16 changed files with 447 additions and 280 deletions
0
APIGateway/Dockerfile
Normal file
0
APIGateway/Dockerfile
Normal file
0
PersonalFinancesService/Dockerfile
Normal file
0
PersonalFinancesService/Dockerfile
Normal file
|
@ -1,9 +0,0 @@
|
|||
services:
|
||||
postgres:
|
||||
image: 'postgres:latest'
|
||||
environment:
|
||||
- 'POSTGRES_DB=mydatabase'
|
||||
- 'POSTGRES_PASSWORD=secret'
|
||||
- 'POSTGRES_USER=myuser'
|
||||
ports:
|
||||
- '5432'
|
|
@ -39,7 +39,8 @@ public class StatisticsController extends AbstractRestController {
|
|||
public ResponseEntity<APIResponseDTO<SpendingByCategoriesDTO>> fetchSpendingByCategory(
|
||||
Long[] categoryId,
|
||||
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
|
||||
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to
|
||||
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to,
|
||||
@RequestParam(defaultValue = "false") Boolean includeUncategorized
|
||||
) {
|
||||
return ok(statisticsService.spendingByCategory(categoryId, from, to));
|
||||
}
|
||||
|
@ -50,9 +51,21 @@ public class StatisticsController extends AbstractRestController {
|
|||
Long[] categoryId,
|
||||
@RequestParam(defaultValue = "DAILY") TimePeriod period,
|
||||
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
|
||||
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to
|
||||
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to,
|
||||
@RequestParam(defaultValue = "false") Boolean includeUncategorized
|
||||
) {
|
||||
return ok(statisticsService.spendingByCategoryOverTime(categoryId, period, from, to));
|
||||
}
|
||||
|
||||
@GetMapping("/sumByCategory")
|
||||
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
|
||||
public ResponseEntity<APIResponseDTO<Double>> sum(
|
||||
Long[] categoryId,
|
||||
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
|
||||
@RequestParam(defaultValue = "2099-01-01T00:00:00") LocalDateTime to,
|
||||
@RequestParam(defaultValue = "false") Boolean includeUncategorized
|
||||
) {
|
||||
return ok(statisticsService.sumByCategory(categoryId, from, to, includeUncategorized));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import dev.mvvasilev.common.data.PersistableEnum;
|
|||
|
||||
public enum WidgetType implements PersistableEnum<String> {
|
||||
TOTAL_SPENDING_PER_CATEGORY,
|
||||
SPENDING_OVER_TIME_PER_CATEGORY;
|
||||
SPENDING_OVER_TIME_PER_CATEGORY,
|
||||
SUM_PER_CATEGORY;
|
||||
|
||||
@Override
|
||||
public String value() {
|
||||
|
|
|
@ -29,17 +29,17 @@ public class StatisticsRepository {
|
|||
|
||||
public Map<Long, Double> fetchSpendingByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to) {
|
||||
Query nativeQuery = entityManager.createNativeQuery(
|
||||
"""
|
||||
SELECT ptc.category_id AS category_id, ROUND(CAST(SUM(pt.amount) AS NUMERIC), 2) AS total_spending
|
||||
FROM transactions.processed_transaction AS pt
|
||||
JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id
|
||||
WHERE
|
||||
pt.is_inflow = FALSE
|
||||
AND ptc.category_id = any(?1)
|
||||
AND (pt.timestamp BETWEEN ?2 AND ?3)
|
||||
GROUP BY ptc.category_id
|
||||
ORDER BY total_spending DESC;
|
||||
""",
|
||||
"""
|
||||
SELECT ptc.category_id AS category_id, ROUND(CAST(SUM(pt.amount) AS NUMERIC), 2) AS total_spending
|
||||
FROM transactions.processed_transaction AS pt
|
||||
JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id
|
||||
WHERE
|
||||
pt.is_inflow = FALSE
|
||||
AND ptc.category_id = any(?1)
|
||||
AND (pt.timestamp BETWEEN ?2 AND ?3)
|
||||
GROUP BY ptc.category_id
|
||||
ORDER BY total_spending DESC;
|
||||
""",
|
||||
Tuple.class
|
||||
);
|
||||
|
||||
|
@ -70,4 +70,23 @@ public class StatisticsRepository {
|
|||
((Tuple) r).get("period_beginning_timestamp", Timestamp.class).toLocalDateTime()
|
||||
)).toList();
|
||||
}
|
||||
|
||||
public Double sumByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to, Boolean includeUncategorized) {
|
||||
Query nativeQuery = entityManager.createNativeQuery(
|
||||
"""
|
||||
SELECT SUM(pt.amount) AS result
|
||||
FROM transactions.processed_transaction AS pt
|
||||
LEFT OUTER JOIN categories.processed_transaction_category AS ptc ON pt.id = ptc.processed_transaction_id
|
||||
WHERE (ptc.category_id = any(?1) OR (?4 AND ptc.category_id IS NULL)) AND (pt.timestamp BETWEEN ?2 AND ?3)
|
||||
""",
|
||||
Tuple.class
|
||||
);
|
||||
|
||||
nativeQuery.setParameter(1, categoryId);
|
||||
nativeQuery.setParameter(2, from);
|
||||
nativeQuery.setParameter(3, to);
|
||||
nativeQuery.setParameter(4, includeUncategorized);
|
||||
|
||||
return ((Tuple) nativeQuery.getSingleResult()).get("result", Double.class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package dev.mvvasilev.finances.persistence;
|
|||
|
||||
import dev.mvvasilev.finances.entity.WidgetParameter;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
|
@ -12,4 +13,7 @@ public interface WidgetParameterRepository extends JpaRepository<WidgetParameter
|
|||
|
||||
Collection<WidgetParameter> findAllByWidgetIdIn(Collection<Long> widgetIds);
|
||||
|
||||
@Modifying
|
||||
void deleteAllByWidgetId(Long widgetId);
|
||||
|
||||
}
|
||||
|
|
|
@ -75,4 +75,8 @@ public class StatisticsService {
|
|||
case YEARLY -> ChronoUnit.YEARS.between(from, to) <= 30;
|
||||
};
|
||||
}
|
||||
|
||||
public Double sumByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to, Boolean includeUncategorized) {
|
||||
return statisticsRepository.sumByCategory(categoryId, from, to, includeUncategorized);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,15 @@ public class WidgetService {
|
|||
|
||||
widgetRepository.saveAndFlush(mapWidget(widget.get(), widget.get().getUserId(), dto));
|
||||
|
||||
widgetParameterRepository.deleteAllByWidgetId(id);
|
||||
|
||||
final var params = dto.parameters()
|
||||
.stream()
|
||||
.map(p -> mapWidgetParameter(new WidgetParameter(), id, p))
|
||||
.toList();
|
||||
|
||||
widgetParameterRepository.saveAllAndFlush(params);
|
||||
|
||||
return 1; // TODO: fetch rows affected from database
|
||||
}
|
||||
|
||||
|
|
0
docker-compose.yml
Normal file
0
docker-compose.yml
Normal file
|
@ -11,6 +11,7 @@ import Grid from "@mui/material/Unstable_Grid2";
|
|||
import Button from "@mui/material/Button";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import {
|
||||
Backdrop,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
|
@ -26,6 +27,8 @@ 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";
|
||||
import Card from "@mui/material/Card";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
export default function CategoriesPage() {
|
||||
|
||||
|
@ -220,80 +223,177 @@ export default function CategoriesPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={1}>
|
||||
<div>
|
||||
<Grid container spacing={1}>
|
||||
|
||||
<Grid container xs={12} lg={12}>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="contained" startIcon={<AddIcon />} onClick={() => openCategoryModal(true)}>
|
||||
Add Category
|
||||
</Button>
|
||||
<Grid container xs={12} lg={12}>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="contained" startIcon={<AddIcon />} onClick={() => openCategoryModal(true)}>
|
||||
Add Category
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<CategoryIcon />} onClick={() => openApplyRulesConfirmModal(true)}>
|
||||
Apply Rules
|
||||
</Button>
|
||||
</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={1} lg={1}>
|
||||
<Button sx={{ width:"100%" }} variant="outlined" startIcon={<CategoryIcon />} onClick={() => openApplyRulesConfirmModal(true)}>
|
||||
Apply Rules
|
||||
</Button>
|
||||
</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}>
|
||||
<CategoriesBox
|
||||
categories={categories}
|
||||
minHeight={"100px"}
|
||||
maxHeight={"250px"}
|
||||
selectable
|
||||
selected={selectedCategory}
|
||||
onCategorySelect={(e, c) => setSelectedCategory({...c})}
|
||||
onCategoryDelete={(e, c) => {
|
||||
setSelectedCategory(c);
|
||||
openConfirmDeleteCategoryModal(true);
|
||||
}}
|
||||
showDelete
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} lg={12}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} lg={12}>
|
||||
{
|
||||
selectedCategory &&
|
||||
<CategorizationRulesEditor
|
||||
selectedCategory={selectedCategory}
|
||||
onRuleBehaviorSelect={(value) => {
|
||||
selectedCategory.ruleBehavior = value;
|
||||
setSelectedCategory({...selectedCategory});
|
||||
<Grid xs={12} lg={12}>
|
||||
<CategoriesBox
|
||||
categories={categories}
|
||||
minHeight={"100px"}
|
||||
maxHeight={"250px"}
|
||||
selectable
|
||||
selected={selectedCategory}
|
||||
onCategorySelect={(e, c) => setSelectedCategory({...c})}
|
||||
onCategoryDelete={(e, c) => {
|
||||
setSelectedCategory(c);
|
||||
openConfirmDeleteCategoryModal(true);
|
||||
}}
|
||||
onSave={() => saveCategory(selectedCategory)}
|
||||
showDelete
|
||||
/>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} lg={12}>
|
||||
<Divider></Divider>
|
||||
</Grid>
|
||||
|
||||
<Grid xs={12} lg={12}>
|
||||
{
|
||||
selectedCategory &&
|
||||
<CategorizationRulesEditor
|
||||
selectedCategory={selectedCategory}
|
||||
onRuleBehaviorSelect={(value) => {
|
||||
selectedCategory.ruleBehavior = value;
|
||||
setSelectedCategory({...selectedCategory});
|
||||
}}
|
||||
onSave={() => saveCategory(selectedCategory)}
|
||||
/>
|
||||
}
|
||||
</Grid>
|
||||
<Dialog
|
||||
open={showConfirmDeleteCategoryModal}
|
||||
>
|
||||
<DialogTitle id="delete-category-dialog-title">
|
||||
{`Delete Category "${selectedCategory?.name}"?`}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Deleting a category will also clear it from all transactions it is currently applied to
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={deleteSelectedCategory}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openConfirmDeleteCategoryModal(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={showApplyRulesConfirmModal}
|
||||
>
|
||||
<DialogTitle id="apply-rules-dialog-title">
|
||||
{"Apply all categorization rules?"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Applying all categorization rules to your current transactions will wipe all categories
|
||||
assigned to them, and re-assign them based on the rules as currently defined.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={applyCategorizationRules}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openApplyRulesConfirmModal(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
<Modal
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
height: "fit-content",
|
||||
p: 4
|
||||
}}
|
||||
open={isCategoryModalOpen}
|
||||
>
|
||||
<Box>
|
||||
<h3>Create New Category</h3>
|
||||
<Card
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
height: "fit-content",
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ pb: 1, fontSize: "1.25em"}}>Create New Category</Typography>
|
||||
<Divider></Divider>
|
||||
<Grid container spacing={1}>
|
||||
<Grid xs={12} lg={12}>
|
||||
|
@ -326,103 +426,8 @@ export default function CategoriesPage() {
|
|||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Card>
|
||||
</Modal>
|
||||
<Dialog
|
||||
open={showConfirmDeleteCategoryModal}
|
||||
>
|
||||
<DialogTitle id="delete-category-dialog-title">
|
||||
{`Delete Category "${selectedCategory?.name}"?`}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Deleting a category will also clear it from all transactions it is currently applied to
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={deleteSelectedCategory}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openConfirmDeleteCategoryModal(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={showApplyRulesConfirmModal}
|
||||
>
|
||||
<DialogTitle id="apply-rules-dialog-title">
|
||||
{"Apply all categorization rules?"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Applying all categorization rules to your current transactions will wipe all categories
|
||||
assigned to them, and re-assign them based on the rules as currently defined.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={applyCategorizationRules}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openApplyRulesConfirmModal(false)}
|
||||
autoFocus
|
||||
variant="contained"
|
||||
>
|
||||
Cancel
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -129,8 +129,30 @@ export default function StatisticsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function updateExistingWidget() {
|
||||
async function updateExistingWidget(widget) {
|
||||
utils.showSpinner();
|
||||
openWidgetModal(false);
|
||||
|
||||
await utils.performRequest(`/api/widgets/${widget.dbId}`, {
|
||||
method: "PUT",
|
||||
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(r => fetchWidgets())
|
||||
.then(resp => {
|
||||
utils.hideSpinner();
|
||||
});
|
||||
}
|
||||
|
||||
function removeWidget() {
|
||||
|
|
|
@ -10,45 +10,43 @@ 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';
|
||||
import {Line, Pie} from 'react-chartjs-2';
|
||||
|
||||
export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
var queryString = "";
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
var includeUncategorized = widget.parameters?.find(p => p.name === PARAMS.INCLUDE_UNCATEGORIZED)?.booleanValue ?? false;
|
||||
|
||||
queryString += `&fromDate=${fromDate}`;
|
||||
queryString += `&toDate=${toDate}`;
|
||||
queryString += `&includeUncategorized=${includeUncategorized}`;
|
||||
|
||||
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));
|
||||
|
@ -56,7 +54,16 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
|||
break;
|
||||
}
|
||||
case "SPENDING_OVER_TIME_PER_CATEGORY": {
|
||||
utils.performRequest("/api/statistics/spendingOverTimeByCategory")
|
||||
queryString += widget.parameters?.find(p => p.name === PARAMS.TIME_PERIOD)?.stringValue ?? "DAILY";
|
||||
|
||||
utils.performRequest(`/api/statistics/spendingOverTimeByCategory?${queryString}`)
|
||||
.then(resp => resp.json())
|
||||
.then(resp => setData(resp.result));
|
||||
|
||||
break;
|
||||
}
|
||||
case "SUM_PER_CATEGORY": {
|
||||
utils.performRequest(`/api/statistics/sumByCategory?${queryString}`)
|
||||
.then(resp => resp.json())
|
||||
.then(resp => setData(resp.result));
|
||||
|
||||
|
@ -109,26 +116,38 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
|||
<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 className={"grid-drag-cancel"} 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])
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{
|
||||
data && widget.type === "SPENDING_OVER_TIME_PER_CATEGORY" &&
|
||||
<Line />
|
||||
}
|
||||
{
|
||||
data && widget.type === "SUM_PER_CATEGORY" &&
|
||||
<Typography sx={{
|
||||
fontSize: "2.3em"
|
||||
}}>
|
||||
{ utils.formatCurrency(data) }
|
||||
</Typography>
|
||||
}
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
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 {
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Menu,
|
||||
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";
|
||||
|
@ -11,6 +21,7 @@ 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";
|
||||
import Card from "@mui/material/Card";
|
||||
|
||||
export default function WidgetEditModal(
|
||||
{
|
||||
|
@ -30,7 +41,10 @@ export default function WidgetEditModal(
|
|||
useEffect(() => {
|
||||
setWidget({
|
||||
...initialWidget,
|
||||
selectedCategories: initialWidget.parameters?.filter(p => p.name.includes("category")).map(p => p.numericValue) ?? []
|
||||
parameters: initialWidget.parameters?.reduce((acc, item) => {
|
||||
acc[item.name] = { ...item, name: undefined };
|
||||
return acc;
|
||||
}, {}) ?? {}
|
||||
});
|
||||
}, [initialWidget]);
|
||||
|
||||
|
@ -48,56 +62,96 @@ export default function WidgetEditModal(
|
|||
.then(resp => setTimePeriods(resp.result));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(widget);
|
||||
}, [widget]);
|
||||
|
||||
function widgetParams(name, defaultValue) {
|
||||
if (!widget.parameters) {
|
||||
widget.parameters = [];
|
||||
widget.parameters = {};
|
||||
}
|
||||
|
||||
let val = widget.parameters?.find(p => p.name === name);
|
||||
let val = widget.parameters[name];
|
||||
|
||||
if (val) {
|
||||
return val;
|
||||
}
|
||||
|
||||
let newVal = {
|
||||
name: name
|
||||
};
|
||||
let newVal = {};
|
||||
|
||||
defaultValue(newVal);
|
||||
|
||||
widget.parameters.push(newVal);
|
||||
widget.parameters[name] = newVal;
|
||||
|
||||
setWidget({...widget});
|
||||
|
||||
return newVal;
|
||||
}
|
||||
|
||||
function widgetParamsMultiselect(prefix, fetchValue) {
|
||||
return Object.entries(widget.parameters)
|
||||
.filter(([name, value]) => name.startsWith(prefix))
|
||||
.map(([name, value]) => fetchValue(value));
|
||||
}
|
||||
|
||||
function setWidgetParam(name, setParam) {
|
||||
var param = widget.parameters?.find(p => p.name === name);
|
||||
var param = widget.parameters[name];
|
||||
|
||||
if (!param) {
|
||||
widget.parameters = [...widget.parameters];
|
||||
widget.parameters.push({
|
||||
name: name
|
||||
});
|
||||
param = widget.parameters?.find(p => p.name === name);
|
||||
widget.parameters = {...widget.parameters};
|
||||
widget.parameters[name] = {};
|
||||
param = widget.parameters[name];
|
||||
}
|
||||
|
||||
setParam(param);
|
||||
setWidget({...widget});
|
||||
}
|
||||
|
||||
function setWidgetParamsMultiselect(prefix, selected, setParam) {
|
||||
// First, clear the current parameters of all elements with the prefix
|
||||
// Then, re-insert the selected elements
|
||||
|
||||
widget.parameters = Object.entries(widget.parameters)
|
||||
.filter(([name, value]) => !name.startsWith(prefix))
|
||||
.reduce((acc, item) => {
|
||||
acc[item.name] = { ...item, name: undefined };
|
||||
return acc;
|
||||
});
|
||||
|
||||
selected.forEach((value, i) => {
|
||||
widget.parameters[`${prefix}-${i}`] = {};
|
||||
setParam(widget.parameters[`${prefix}-${i}`], value);
|
||||
})
|
||||
|
||||
setWidget({...widget});
|
||||
}
|
||||
|
||||
function mapWidget() {
|
||||
widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
|
||||
return {
|
||||
name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
|
||||
numericValue: c
|
||||
}
|
||||
}));
|
||||
// widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
|
||||
// return {
|
||||
// name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
|
||||
// numericValue: c
|
||||
// }
|
||||
// }));
|
||||
|
||||
widget.selectedCategories = undefined;
|
||||
// widget.selectedCategories = undefined;
|
||||
|
||||
return {...widget}
|
||||
return {
|
||||
...widget,
|
||||
selectedCategories: undefined,
|
||||
parameters: Object.entries(widget.parameters).map(([name, value]) => {
|
||||
return {
|
||||
...value,
|
||||
name: name
|
||||
}
|
||||
})
|
||||
// .concat(widget.selectedCategories?.map((c, i) => {
|
||||
// return {
|
||||
// name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
|
||||
// numericValue: c
|
||||
// }
|
||||
// }))
|
||||
}
|
||||
}
|
||||
|
||||
function onEditWidget() {
|
||||
|
@ -111,23 +165,24 @@ export default function WidgetEditModal(
|
|||
return (
|
||||
widget &&
|
||||
<Modal
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
height: "fit-content",
|
||||
p: 4
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<Box>
|
||||
<Card
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
height: "fit-content",
|
||||
p: 4
|
||||
}}
|
||||
>
|
||||
{
|
||||
widget.dbId ? (
|
||||
<h3>Editing Widget</h3>
|
||||
<Typography sx={{ pb: 1, fontSize: "1.25em"}}>Editing Widget</Typography>
|
||||
) : (
|
||||
<h3>Create New Widget</h3>
|
||||
<Typography sx={{ pb: 1, fontSize: "1.25em"}}>Create New Widget</Typography>
|
||||
)
|
||||
}
|
||||
<Divider></Divider>
|
||||
|
@ -249,7 +304,9 @@ export default function WidgetEditModal(
|
|||
<Grid xs={12} lg={12}>
|
||||
<Select
|
||||
id={"time-period-type"}
|
||||
sx={{ width: "100%" }}
|
||||
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);
|
||||
|
@ -271,21 +328,35 @@ export default function WidgetEditModal(
|
|||
</Select>
|
||||
</Grid>
|
||||
}
|
||||
<Grid xs={12} lg={12}>
|
||||
<FormControlLabel
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
value="end"
|
||||
control={
|
||||
<Checkbox
|
||||
checked={widgetParams(PARAMS.INCLUDE_UNCATEGORIZED, val => val.booleanValue = false)?.booleanValue}
|
||||
onChange={(e) => {
|
||||
setWidgetParam(PARAMS.INCLUDE_UNCATEGORIZED, p => p.booleanValue = e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Include Uncategorized"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid xs={12} lg={12}>
|
||||
<Select
|
||||
sx={{ width: "100%", height: "100%" }}
|
||||
input={<OutlinedInput label="Categories" />}
|
||||
multiple
|
||||
value={widget.selectedCategories ?? []}
|
||||
value={widgetParamsMultiselect(PARAMS.CATEGORY_PREFIX, (v) => v.numericValue) ?? []}
|
||||
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});
|
||||
setWidgetParamsMultiselect(PARAMS.CATEGORY_PREFIX, e.target.value, (param, value) => param.numericValue = value);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="placeholder" disabled>
|
||||
|
@ -294,7 +365,7 @@ export default function WidgetEditModal(
|
|||
{
|
||||
categories.map(c => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
<Checkbox checked={widget.selectedCategories?.findIndex(cat => cat === c.id) > -1} />
|
||||
<Checkbox checked={widgetParamsMultiselect(PARAMS.CATEGORY_PREFIX, (v) => v.numericValue)?.findIndex(cat => cat === c.id) > -1} />
|
||||
<Typography>{ c.name }</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
|
@ -335,7 +406,7 @@ export default function WidgetEditModal(
|
|||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Card>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -5,5 +5,6 @@ export const PARAMS = {
|
|||
IS_FROM_DATE_STATIC: "isFromDateStatic",
|
||||
IS_TO_NOW: "isToDateToNow",
|
||||
TIME_PERIOD: "timePeriod",
|
||||
CATEGORY_PREFIX: "category"
|
||||
CATEGORY_PREFIX: "category",
|
||||
INCLUDE_UNCATEGORIZED: "includeUncategorized"
|
||||
}
|
|
@ -1,5 +1,10 @@
|
|||
import { v4 } from 'uuid';
|
||||
|
||||
let LEV_FORMAT = new Intl.NumberFormat('bg-BG', {
|
||||
style: 'currency',
|
||||
currency: 'BGN',
|
||||
});
|
||||
|
||||
let utils = {
|
||||
performRequest: async (url, options) => {
|
||||
return await fetch(url, options).then(resp => {
|
||||
|
@ -33,6 +38,9 @@ let utils = {
|
|||
generateUUID: () => v4(),
|
||||
isNumeric: (value) => {
|
||||
return /^-?\d+(\.\d+)?$/.test(value);
|
||||
},
|
||||
formatCurrency(number) {
|
||||
return LEV_FORMAT.format(number);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue