Added Sum Widget

This commit is contained in:
Miroslav Vasilev 2024-01-03 22:46:16 +02:00
parent 3ec459da6f
commit 51465ad44d
16 changed files with 447 additions and 280 deletions

0
APIGateway/Dockerfile Normal file
View file

View file

View file

@ -1,9 +0,0 @@
services:
postgres:
image: 'postgres:latest'
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
ports:
- '5432'

View file

@ -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));
}
} }

View file

@ -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() {

View file

@ -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);
}
} }

View file

@ -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);
} }

View file

@ -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);
}
} }

View file

@ -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
View file

View 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,6 +223,7 @@ export default function CategoriesPage() {
} }
return ( return (
<div>
<Grid container spacing={1}> <Grid container spacing={1}>
<Grid container xs={12} lg={12}> <Grid container xs={12} lg={12}>
@ -279,55 +283,6 @@ export default function CategoriesPage() {
/> />
} }
</Grid> </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>
<Divider></Divider>
<Grid container spacing={1}>
<Grid xs={12} lg={12}>
<TextField
id="category-name"
label="Category Name"
variant="outlined"
onChange={(e) => setNewCategoryName(e.target.value)}
autoFocus
sx={{width: "100%"}}
/>
</Grid>
<Grid xs={6} lg={6}>
<Button
sx={{width: "100%"}}
variant="contained"
onClick={createNewCategory}
startIcon={<SaveIcon />}
>
Create
</Button>
</Grid>
<Grid xs={6} lg={6}>
<Button
sx={{width: "100%"}}
onClick={() => openCategoryModal(false)}
startIcon={<CloseIcon />}
>
Cancel
</Button>
</Grid>
</Grid>
</Box>
</Modal>
<Dialog <Dialog
open={showConfirmDeleteCategoryModal} open={showConfirmDeleteCategoryModal}
> >
@ -424,5 +379,55 @@ export default function CategoriesPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Grid> </Grid>
<Modal
open={isCategoryModalOpen}
>
<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}>
<TextField
id="category-name"
label="Category Name"
variant="outlined"
onChange={(e) => setNewCategoryName(e.target.value)}
autoFocus
sx={{width: "100%"}}
/>
</Grid>
<Grid xs={6} lg={6}>
<Button
sx={{width: "100%"}}
variant="contained"
onClick={createNewCategory}
startIcon={<SaveIcon />}
>
Create
</Button>
</Grid>
<Grid xs={6} lg={6}>
<Button
sx={{width: "100%"}}
onClick={() => openCategoryModal(false)}
startIcon={<CloseIcon />}
>
Cancel
</Button>
</Grid>
</Grid>
</Card>
</Modal>
</div>
); );
} }

View file

@ -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() {

View file

@ -10,27 +10,20 @@ 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(() => {
switch (widget.type) {
case "TOTAL_SPENDING_PER_CATEGORY": {
var queryString = ""; var queryString = "";
console.log(widget);
queryString += widget.parameters?.filter(p => p.name.includes(PARAMS.CATEGORY_PREFIX)).map(c => `categoryId=${c.numericValue}`)?.join("&"); 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 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; let isFromStatic = widget.parameters?.find(p => p.name === PARAMS.IS_FROM_DATE_STATIC)?.booleanValue ?? false;
console.log(isToNow);
console.log(isFromStatic);
var fromDate; var fromDate;
var toDate; var toDate;
@ -46,9 +39,14 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
fromDate = dayjs(widget.parameters?.find(p => p.name === PARAMS.FROM_DATE)?.timestampValue); 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 += `&fromDate=${fromDate}`;
queryString += `&toDate=${toDate}`; queryString += `&toDate=${toDate}`;
queryString += `&includeUncategorized=${includeUncategorized}`;
switch (widget.type) {
case "TOTAL_SPENDING_PER_CATEGORY": {
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,7 +116,7 @@ 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
@ -129,6 +136,18 @@ export default function WidgetContainer({widget, sx, onEdit, onRemove}) {
}} }}
/> />
} }
{
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>

View file

@ -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 mapWidget() { function setWidgetParamsMultiselect(prefix, selected, setParam) {
widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => { // First, clear the current parameters of all elements with the prefix
return { // Then, re-insert the selected elements
name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
numericValue: c 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});
} }
}));
widget.selectedCategories = undefined; function mapWidget() {
// widget.parameters = widget.parameters.concat(widget.selectedCategories?.map((c, i) => {
// return {
// name: `${PARAMS.CATEGORY_PREFIX}-${i}`,
// numericValue: c
// }
// }));
return {...widget} // widget.selectedCategories = undefined;
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,6 +165,9 @@ export default function WidgetEditModal(
return ( return (
widget && widget &&
<Modal <Modal
open={open}
>
<Card
sx={{ sx={{
position: "absolute", position: "absolute",
top: "50%", top: "50%",
@ -120,14 +177,12 @@ export default function WidgetEditModal(
height: "fit-content", height: "fit-content",
p: 4 p: 4
}} }}
open={open}
> >
<Box>
{ {
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>
); );
} }

View file

@ -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"
} }

View file

@ -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);
} }
} }