- Budget-Donut: Mittelbetrag passt sich der Länge an (kein Hineinlaufen in den Ring bei 4-stelligen Beträgen) - Vermögensverlauf: Y-Achse ohne Währungssymbol (formatAmount) + etwas mehr linker Rand → „CHF …" wird nicht mehr abgeschnitten - Karten-Optionen: im Anordnen-Modus je Karte einstellbar; Budget-Donut und Budget-Fortschritt können nach Gruppen ODER Kategorien aufschlüsseln (?group_by=), Auswahl wird im Layout (props) mitgespeichert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
6.5 KiB
React
182 lines
6.5 KiB
React
import { createElement, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import GridLayout, { WidthProvider } from "react-grid-layout";
|
|
import "react-grid-layout/css/styles.css";
|
|
import "react-resizable/css/styles.css";
|
|
import { api } from "../api";
|
|
import { useConfirm } from "../confirm";
|
|
import { useToast } from "../toast";
|
|
import Icon from "../components/Icon";
|
|
import { KARTEN, KARTEN_TYPEN } from "../dashboard/cards";
|
|
|
|
const Grid = WidthProvider(GridLayout);
|
|
|
|
export default function Dashboard() {
|
|
const { t } = useTranslation();
|
|
const confirm = useConfirm();
|
|
const toast = useToast();
|
|
const [items, setItems] = useState(null);
|
|
const [edit, setEdit] = useState(false);
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
|
|
function normalize(it) {
|
|
const def = KARTEN[it.type] || {};
|
|
return { i: it.i || it.type, minW: def.minW || 2, minH: def.minH || 2, ...it };
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const res = await api.dashboardLayout();
|
|
setItems(res.layout.filter((it) => KARTEN[it.type]).map(normalize));
|
|
} catch (e) {
|
|
toast(e.message, "error");
|
|
setItems([]);
|
|
}
|
|
}
|
|
useEffect(() => { load(); }, []);
|
|
|
|
function merge(layout) {
|
|
const byId = Object.fromEntries((items || []).map((it) => [it.i, it]));
|
|
return layout.map((l) => ({ ...byId[l.i], x: l.x, y: l.y, w: l.w, h: l.h }));
|
|
}
|
|
|
|
async function persist(next) {
|
|
setItems(next);
|
|
try {
|
|
await api.saveDashboardLayout(next.map((it) => ({
|
|
i: it.i, type: it.type, x: it.x, y: it.y, w: it.w, h: it.h, props: it.props,
|
|
})));
|
|
} catch (e) {
|
|
toast(e.message, "error");
|
|
}
|
|
}
|
|
|
|
// Eine Karten-Option (z. B. Aufschlüsselung Gruppen/Kategorien) setzen + sichern.
|
|
function setCardProp(i, key, value) {
|
|
persist(items.map((it) => (it.i === i ? { ...it, props: { ...(it.props || {}), [key]: value } } : it)));
|
|
}
|
|
|
|
function addCard(type) {
|
|
const def = KARTEN[type];
|
|
const maxY = items.reduce((m, it) => Math.max(m, it.y + it.h), 0);
|
|
setAddOpen(false);
|
|
persist([...items, normalize({ i: type, type, x: 0, y: maxY, w: def.w, h: def.h })]);
|
|
}
|
|
function removeCard(i) {
|
|
persist(items.filter((it) => it.i !== i));
|
|
}
|
|
async function reset() {
|
|
if (!(await confirm({ title: t("dashboard.reset"), message: t("dashboard.resetConfirm"), confirmLabel: t("dashboard.reset") }))) return;
|
|
try {
|
|
const res = await api.resetDashboardLayout();
|
|
setItems(res.layout.filter((it) => KARTEN[it.type]).map(normalize));
|
|
setEdit(false);
|
|
} catch (e) {
|
|
toast(e.message, "error");
|
|
}
|
|
}
|
|
|
|
if (!items) return <div className="muted">{t("common.loading")}</div>;
|
|
|
|
const present = new Set(items.map((it) => it.type));
|
|
const missing = KARTEN_TYPEN.filter((tp) => !present.has(tp));
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-head">
|
|
<h1>{t("dashboard.title")}</h1>
|
|
<div className="dash-toolbar">
|
|
{edit && missing.length > 0 && (
|
|
<div className="dash-add">
|
|
<button className="btn sm" onClick={() => setAddOpen((o) => !o)}>
|
|
<Icon name="plus" size={15} /> {t("dashboard.addCard")}
|
|
</button>
|
|
{addOpen && (
|
|
<div className="dash-add-menu">
|
|
{missing.map((tp) => (
|
|
<button key={tp} className="dash-add-item" onClick={() => addCard(tp)}>
|
|
{t(KARTEN[tp].titleKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{edit && (
|
|
<button className="btn sm" onClick={reset}>
|
|
<Icon name="refresh-ccw" size={14} /> {t("dashboard.reset")}
|
|
</button>
|
|
)}
|
|
<button className={`btn sm ${edit ? "primary" : ""}`} onClick={() => { setEdit((e) => !e); setAddOpen(false); }}>
|
|
<Icon name={edit ? "check" : "edit"} size={14} /> {edit ? t("dashboard.done") : t("dashboard.edit")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{items.length === 0 ? (
|
|
<div className="card">
|
|
<div className="empty-state">
|
|
<Icon name="pie-chart" size={40} />
|
|
<p>{t("dashboard.emptyCards")}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Grid
|
|
className={`dash-grid${edit ? " editing" : ""}`}
|
|
cols={12}
|
|
rowHeight={44}
|
|
margin={[16, 16]}
|
|
isDraggable={edit}
|
|
isResizable={edit}
|
|
draggableHandle=".dash-card-head"
|
|
layout={items}
|
|
onLayoutChange={(l) => setItems(merge(l))}
|
|
onDragStop={(l) => persist(merge(l))}
|
|
onResizeStop={(l) => persist(merge(l))}
|
|
useCSSTransforms
|
|
>
|
|
{items.map((it) => {
|
|
const def = KARTEN[it.type];
|
|
return (
|
|
<div key={it.i} className="dash-card">
|
|
<div className="dash-card-head">
|
|
<span className="dash-card-title">{def ? t(def.titleKey) : it.type}</span>
|
|
{edit && (
|
|
<button className="dash-remove btn-icon" onClick={() => removeCard(it.i)} title={t("common.delete")}>
|
|
<Icon name="close" size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="dash-card-body">
|
|
{edit && def?.options?.length > 0 && (
|
|
<div className="dash-config-bar">
|
|
{def.options.map((opt) => {
|
|
const cur = it.props?.[opt.key] ?? opt.def;
|
|
return (
|
|
<div key={opt.key} className="dash-config-opt">
|
|
<span className="muted small">{t(opt.labelKey)}</span>
|
|
<div className="seg sm">
|
|
{opt.choices.map((ch) => (
|
|
<button key={ch.v} type="button"
|
|
className={`seg-btn${cur === ch.v ? " active" : ""}`}
|
|
onClick={() => setCardProp(it.i, opt.key, ch.v)}>
|
|
{t(ch.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{def ? createElement(def.Comp, { key: JSON.stringify(it.props || {}), props: it.props }) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Grid>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|