Files
certain/web/src/pages/Dashboard.jsx
T
GuidoandClaude Opus 4.8 8d0fbdfe2b feat: Dashboard – Donut ohne Überlauf, Achse lesbar, Karten-Optionen
- 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>
2026-08-20 16:58:32 +02:00

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