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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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