feat: Phase 3 – konfigurierbares Karten-Dashboard
Frontend: - Dashboard-Seite mit react-grid-layout: Karten frei verschieben/skalieren, hinzufügen/entfernen, Standard-Layout zurücksetzen; Layout je Nutzer gespeichert. - Sechs Karten: Kontostände, Budget-Donut, Vermögensverlauf (Linien-Chart), Budget-Fortschritt (grün/rot), Einnahmen/Ausgaben, letzte Buchungen. - SVG-Charts (Donut/Line) mit währungsbewusster Formatierung; i18n DE/EN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+139
-6
@@ -1,18 +1,151 @@
|
||||
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);
|
||||
|
||||
// Platzhalter: das konfigurierbare Karten-Dashboard wird in Phase 3 aufgebaut.
|
||||
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 })));
|
||||
} catch (e) {
|
||||
toast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<h1>{t("dashboard.title")}</h1>
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<Icon name="pie-chart" size={40} />
|
||||
<p>{t("dashboard.empty")}</p>
|
||||
<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">{def ? createElement(def.Comp) : null}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user