mirror of
https://github.com/mvvasilev/personal-finances.git
synced 2025-04-19 14:19: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(
|
public ResponseEntity<APIResponseDTO<SpendingByCategoriesDTO>> fetchSpendingByCategory(
|
||||||
Long[] categoryId,
|
Long[] categoryId,
|
||||||
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
|
@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));
|
return ok(statisticsService.spendingByCategory(categoryId, from, to));
|
||||||
}
|
}
|
||||||
|
@ -50,9 +51,21 @@ public class StatisticsController extends AbstractRestController {
|
||||||
Long[] categoryId,
|
Long[] categoryId,
|
||||||
@RequestParam(defaultValue = "DAILY") TimePeriod period,
|
@RequestParam(defaultValue = "DAILY") TimePeriod period,
|
||||||
@RequestParam(defaultValue = "1970-01-01T00:00:00") LocalDateTime from,
|
@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));
|
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> {
|
public enum WidgetType implements PersistableEnum<String> {
|
||||||
TOTAL_SPENDING_PER_CATEGORY,
|
TOTAL_SPENDING_PER_CATEGORY,
|
||||||
SPENDING_OVER_TIME_PER_CATEGORY;
|
SPENDING_OVER_TIME_PER_CATEGORY,
|
||||||
|
SUM_PER_CATEGORY;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String value() {
|
public String value() {
|
||||||
|
|
|
@ -29,17 +29,17 @@ public class StatisticsRepository {
|
||||||
|
|
||||||
public Map<Long, Double> fetchSpendingByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to) {
|
public Map<Long, Double> fetchSpendingByCategory(Long[] categoryId, LocalDateTime from, LocalDateTime to) {
|
||||||
Query nativeQuery = entityManager.createNativeQuery(
|
Query nativeQuery = entityManager.createNativeQuery(
|
||||||
"""
|
"""
|
||||||
SELECT ptc.category_id AS category_id, ROUND(CAST(SUM(pt.amount) AS NUMERIC), 2) AS total_spending
|
SELECT ptc.category_id AS category_id, ROUND(CAST(SUM(pt.amount) AS NUMERIC), 2) AS total_spending
|
||||||
FROM transactions.processed_transaction AS pt
|
FROM transactions.processed_transaction AS pt
|
||||||
JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id
|
JOIN categories.processed_transaction_category AS ptc ON ptc.processed_transaction_id = pt.id
|
||||||
WHERE
|
WHERE
|
||||||
pt.is_inflow = FALSE
|
pt.is_inflow = FALSE
|
||||||
AND ptc.category_id = any(?1)
|
AND ptc.category_id = any(?1)
|
||||||
AND (pt.timestamp BETWEEN ?2 AND ?3)
|
AND (pt.timestamp BETWEEN ?2 AND ?3)
|
||||||
GROUP BY ptc.category_id
|
GROUP BY ptc.category_id
|
||||||
ORDER BY total_spending DESC;
|
ORDER BY total_spending DESC;
|
||||||
""",
|
""",
|
||||||
Tuple.class
|
Tuple.class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -70,4 +70,23 @@ public class StatisticsRepository {
|
||||||
((Tuple) r).get("period_beginning_timestamp", Timestamp.class).toLocalDateTime()
|
((Tuple) r).get("period_beginning_timestamp", Timestamp.class).toLocalDateTime()
|
||||||
)).toList();
|
)).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 dev.mvvasilev.finances.entity.WidgetParameter;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@ -12,4 +13,7 @@ public interface WidgetParameterRepository extends JpaRepository<WidgetParameter
|
||||||
|
|
||||||
Collection<WidgetParameter> findAllByWidgetIdIn(Collection<Long> widgetIds);
|
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;
|
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));
|
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
|
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 Button from "@mui/material/Button";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import {
|
import {
|
||||||
|
Backdrop,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
@ -26,6 +27,8 @@ import CategoriesBox from "@/components/categories/CategoriesBox.jsx";
|
||||||
import {PARAMS} from "@/components/widgets/WidgetParameters.js";
|
import {PARAMS} from "@/components/widgets/WidgetParameters.js";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
|
import VisuallyHiddenInput from "@/components/VisuallyHiddenInput.jsx";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
|
|
||||||
|
@ -220,80 +223,177 @@ export default function CategoriesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={1}>
|
<div>
|
||||||
|
<Grid container spacing={1}>
|
||||||
|
|
||||||
<Grid container xs={12} lg={12}>
|
<Grid container xs={12} lg={12}>
|
||||||
<Grid xs={1} lg={1}>
|
<Grid xs={1} lg={1}>
|
||||||
<Button sx={{ width:"100%" }} variant="contained" startIcon={<AddIcon />} onClick={() => openCategoryModal(true)}>
|
<Button sx={{ width:"100%" }} variant="contained" startIcon={<AddIcon />} onClick={() => openCategoryModal(true)}>
|
||||||
Add Category
|
Add Category
|
||||||
</Button>
|
</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>
|
||||||
<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}>
|
<Grid xs={12} lg={12}>
|
||||||
<CategoriesBox
|
<CategoriesBox
|
||||||
categories={categories}
|
categories={categories}
|
||||||
minHeight={"100px"}
|
minHeight={"100px"}
|
||||||
maxHeight={"250px"}
|
maxHeight={"250px"}
|
||||||
selectable
|
selectable
|
||||||
selected={selectedCategory}
|
selected={selectedCategory}
|
||||||
onCategorySelect={(e, c) => setSelectedCategory({...c})}
|
onCategorySelect={(e, c) => setSelectedCategory({...c})}
|
||||||
onCategoryDelete={(e, c) => {
|
onCategoryDelete={(e, c) => {
|
||||||
setSelectedCategory(c);
|
setSelectedCategory(c);
|
||||||
openConfirmDeleteCategoryModal(true);
|
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});
|
|
||||||
}}
|
}}
|
||||||
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
|
<Modal
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: 400,
|
|
||||||
height: "fit-content",
|
|
||||||
p: 4
|
|
||||||
}}
|
|
||||||
open={isCategoryModalOpen}
|
open={isCategoryModalOpen}
|
||||||
>
|
>
|
||||||
<Box>
|
<Card
|
||||||
<h3>Create New Category</h3>
|
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>
|
<Divider></Divider>
|
||||||
<Grid container spacing={1}>
|
<Grid container spacing={1}>
|
||||||
<Grid xs={12} lg={12}>
|
<Grid xs={12} lg={12}>
|
||||||
|
@ -326,103 +426,8 @@ export default function CategoriesPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Card>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Dialog
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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() {
|
function removeWidget() {
|
||||||
|
|
|
@ -10,45 +10,43 @@ import utils from "@/utils.js";
|
||||||
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
|
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import 'chart.js/auto';
|
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}) {
|
export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
||||||
|
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
switch (widget.type) {
|
||||||
case "TOTAL_SPENDING_PER_CATEGORY": {
|
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}`)
|
utils.performRequest(`/api/statistics/totalSpendingByCategory?${queryString}`)
|
||||||
.then(resp => resp.json())
|
.then(resp => resp.json())
|
||||||
.then(resp => setData(resp.result));
|
.then(resp => setData(resp.result));
|
||||||
|
@ -56,7 +54,16 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "SPENDING_OVER_TIME_PER_CATEGORY": {
|
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 => resp.json())
|
||||||
.then(resp => setData(resp.result));
|
.then(resp => setData(resp.result));
|
||||||
|
|
||||||
|
@ -109,26 +116,38 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
|
||||||
<Divider></Divider>
|
<Divider></Divider>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid xs={12} lg={12}>
|
<Grid xs={12} lg={12}>
|
||||||
<div style={{ position: "relative", height: "100%", width: "100%" }}>
|
<div className={"grid-drag-cancel"} style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||||
{
|
{
|
||||||
data && widget.type === "TOTAL_SPENDING_PER_CATEGORY" &&
|
data && widget.type === "TOTAL_SPENDING_PER_CATEGORY" &&
|
||||||
<Pie
|
<Pie
|
||||||
options={{
|
options={{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
aspectRatio: 1
|
aspectRatio: 1
|
||||||
}}
|
}}
|
||||||
data={{
|
data={{
|
||||||
labels: data.categories.map(c => c.name),
|
labels: data.categories.map(c => c.name),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "Amount",
|
label: "Amount",
|
||||||
data: data.categories.map(c => data.spendingByCategory[c.id])
|
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>
|
</div>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
@ -1,7 +1,17 @@
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import Grid from "@mui/material/Unstable_Grid2";
|
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 Typography from "@mui/material/Typography";
|
||||||
import utils from "@/utils.js";
|
import utils from "@/utils.js";
|
||||||
import {DatePicker} from "@mui/x-date-pickers";
|
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 * as React from "react";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
|
import { PARAMS } from "@/components/widgets/WidgetParameters.js";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
|
||||||
export default function WidgetEditModal(
|
export default function WidgetEditModal(
|
||||||
{
|
{
|
||||||
|
@ -30,7 +41,10 @@ export default function WidgetEditModal(
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWidget({
|
setWidget({
|
||||||
...initialWidget,
|
...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]);
|
}, [initialWidget]);
|
||||||
|
|
||||||
|
@ -48,56 +62,96 @@ export default function WidgetEditModal(
|
||||||
.then(resp => setTimePeriods(resp.result));
|
.then(resp => setTimePeriods(resp.result));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(widget);
|
||||||
|
}, [widget]);
|
||||||
|
|
||||||
function widgetParams(name, defaultValue) {
|
function widgetParams(name, defaultValue) {
|
||||||
if (!widget.parameters) {
|
if (!widget.parameters) {
|
||||||
widget.parameters = [];
|
widget.parameters = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let val = widget.parameters?.find(p => p.name === name);
|
let val = widget.parameters[name];
|
||||||
|
|
||||||
if (val) {
|
if (val) {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newVal = {
|
let newVal = {};
|
||||||
name: name
|
|
||||||
};
|
|
||||||
|
|
||||||
defaultValue(newVal);
|
defaultValue(newVal);
|
||||||
|
|
||||||
widget.parameters.push(newVal);
|
widget.parameters[name] = newVal;
|
||||||
|
|
||||||
setWidget({...widget});
|
setWidget({...widget});
|
||||||
|
|
||||||
return newVal;
|
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) {
|
function setWidgetParam(name, setParam) {
|
||||||
var param = widget.parameters?.find(p => p.name === name);
|
var param = widget.parameters[name];
|
||||||
|
|
||||||
if (!param) {
|
if (!param) {
|
||||||
widget.parameters = [...widget.parameters];
|
widget.parameters = {...widget.parameters};
|
||||||
widget.parameters.push({
|
widget.parameters[name] = {};
|
||||||
name: name
|
param = widget.parameters[name];
|
||||||
});
|
|
||||||
param = widget.parameters?.find(p => p.name === name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setParam(param);
|
setParam(param);
|
||||||
setWidget({...widget});
|
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() {
|
function mapWidget() {
|
||||||
widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
|
// widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
|
||||||
return {
|
// return {
|
||||||
name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
|
// name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
|
||||||
numericValue: c
|
// 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() {
|
function onEditWidget() {
|
||||||
|
@ -111,23 +165,24 @@ export default function WidgetEditModal(
|
||||||
return (
|
return (
|
||||||
widget &&
|
widget &&
|
||||||
<Modal
|
<Modal
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
width: 400,
|
|
||||||
height: "fit-content",
|
|
||||||
p: 4
|
|
||||||
}}
|
|
||||||
open={open}
|
open={open}
|
||||||
>
|
>
|
||||||
<Box>
|
<Card
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 400,
|
||||||
|
height: "fit-content",
|
||||||
|
p: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
widget.dbId ? (
|
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>
|
<Divider></Divider>
|
||||||
|
@ -249,7 +304,9 @@ export default function WidgetEditModal(
|
||||||
<Grid xs={12} lg={12}>
|
<Grid xs={12} lg={12}>
|
||||||
<Select
|
<Select
|
||||||
id={"time-period-type"}
|
id={"time-period-type"}
|
||||||
sx={{ width: "100%" }}
|
sx={{
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
value={widgetParams(PARAMS.TIME_PERIOD, val => val.stringValue = "placeholder")?.stringValue}
|
value={widgetParams(PARAMS.TIME_PERIOD, val => val.stringValue = "placeholder")?.stringValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setWidgetParam(PARAMS.TIME_PERIOD, p => p.stringValue = e.target.value);
|
setWidgetParam(PARAMS.TIME_PERIOD, p => p.stringValue = e.target.value);
|
||||||
|
@ -271,21 +328,35 @@ export default function WidgetEditModal(
|
||||||
</Select>
|
</Select>
|
||||||
</Grid>
|
</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}>
|
<Grid xs={12} lg={12}>
|
||||||
<Select
|
<Select
|
||||||
sx={{ width: "100%", height: "100%" }}
|
sx={{ width: "100%", height: "100%" }}
|
||||||
input={<OutlinedInput label="Categories" />}
|
input={<OutlinedInput label="Categories" />}
|
||||||
multiple
|
multiple
|
||||||
value={widget.selectedCategories ?? []}
|
value={widgetParamsMultiselect(PARAMS.CATEGORY_PREFIX, (v) => v.numericValue) ?? []}
|
||||||
renderValue={(selected) => {
|
renderValue={(selected) => {
|
||||||
console.log(selected)
|
|
||||||
return (<Typography>
|
return (<Typography>
|
||||||
{ selected.map(s => categories.find(c => c.id === s)?.name)?.join(", ")}
|
{ selected.map(s => categories.find(c => c.id === s)?.name)?.join(", ")}
|
||||||
</Typography>)
|
</Typography>)
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
widget.selectedCategories = e.target.value;
|
setWidgetParamsMultiselect(PARAMS.CATEGORY_PREFIX, e.target.value, (param, value) => param.numericValue = value);
|
||||||
setWidget({...widget});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="placeholder" disabled>
|
<MenuItem value="placeholder" disabled>
|
||||||
|
@ -294,7 +365,7 @@ export default function WidgetEditModal(
|
||||||
{
|
{
|
||||||
categories.map(c => (
|
categories.map(c => (
|
||||||
<MenuItem key={c.id} value={c.id}>
|
<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>
|
<Typography>{ c.name }</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
|
@ -335,7 +406,7 @@ export default function WidgetEditModal(
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Card>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -5,5 +5,6 @@ export const PARAMS = {
|
||||||
IS_FROM_DATE_STATIC: "isFromDateStatic",
|
IS_FROM_DATE_STATIC: "isFromDateStatic",
|
||||||
IS_TO_NOW: "isToDateToNow",
|
IS_TO_NOW: "isToDateToNow",
|
||||||
TIME_PERIOD: "timePeriod",
|
TIME_PERIOD: "timePeriod",
|
||||||
CATEGORY_PREFIX: "category"
|
CATEGORY_PREFIX: "category",
|
||||||
|
INCLUDE_UNCATEGORIZED: "includeUncategorized"
|
||||||
}
|
}
|
|
@ -1,5 +1,10 @@
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
let LEV_FORMAT = new Intl.NumberFormat('bg-BG', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'BGN',
|
||||||
|
});
|
||||||
|
|
||||||
let utils = {
|
let utils = {
|
||||||
performRequest: async (url, options) => {
|
performRequest: async (url, options) => {
|
||||||
return await fetch(url, options).then(resp => {
|
return await fetch(url, options).then(resp => {
|
||||||
|
@ -33,6 +38,9 @@ let utils = {
|
||||||
generateUUID: () => v4(),
|
generateUUID: () => v4(),
|
||||||
isNumeric: (value) => {
|
isNumeric: (value) => {
|
||||||
return /^-?\d+(\.\d+)?$/.test(value);
|
return /^-?\d+(\.\d+)?$/.test(value);
|
||||||
|
},
|
||||||
|
formatCurrency(number) {
|
||||||
|
return LEV_FORMAT.format(number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue