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:
+1
-1
@@ -19,7 +19,7 @@ Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on-
|
|||||||
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
|
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
|
||||||
kein Rollover in v1.
|
kein Rollover in v1.
|
||||||
|
|
||||||
## Phase 3 – Dashboard
|
## Phase 3 – Dashboard ✅
|
||||||
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
|
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
|
||||||
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
|
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useSettings } from "../settings";
|
||||||
|
import IconChip from "../components/IconChip";
|
||||||
|
import Money from "../components/Money";
|
||||||
|
import { formatMoney } from "../format";
|
||||||
|
import { tokenFarbe } from "./palette";
|
||||||
|
import Donut from "./charts/Donut";
|
||||||
|
import Line from "./charts/Line";
|
||||||
|
|
||||||
|
function useData(loader) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let ok = true;
|
||||||
|
loader().then((d) => ok && setData(d)).catch(() => ok && setData({ __error: true }));
|
||||||
|
return () => { ok = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <div className="dash-loading muted">{t("common.loading")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso) {
|
||||||
|
const [, m, d] = iso.split("-");
|
||||||
|
return `${d}.${m}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountBalances() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { baseCurrency } = useSettings();
|
||||||
|
const data = useData(() => api.cardBalances());
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
if (!data.accounts?.length) return <div className="dash-empty muted">{t("accounts.empty")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="dash-list">
|
||||||
|
{data.accounts.map((a) => (
|
||||||
|
<div className="dash-line" key={a.id}>
|
||||||
|
<IconChip name={a.icon} color={a.color} size={26} />
|
||||||
|
<span className="grow ellipsis">{a.name}</span>
|
||||||
|
<Money cents={a.balance_cents} currency={a.currency} baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="dash-total">
|
||||||
|
<span className="grow">{t("cards.total")}</span>
|
||||||
|
<strong>{formatMoney(data.total, data.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetDonut() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = useData(() => api.cardBudgetDonut());
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
if (!data.segments?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
|
||||||
|
const label = data.mode === "planned" ? t("cards.planned") : t("cards.spent");
|
||||||
|
return <Donut segmente={data.segments} gesamtLabel={label} fmt={(v) => formatMoney(v, data.currency)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetWorth() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = useData(() => api.cardNetWorth(90));
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
const labels = (data.labels || []).map(shortDate);
|
||||||
|
const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
|
||||||
|
return <Line labels={labels} serien={serien} fmt={(v) => formatMoney(v, data.currency)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetProgress() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = useData(() => api.cardBudgetProgress());
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="dash-list">
|
||||||
|
{data.rows.map((r) => {
|
||||||
|
const pct = r.planned > 0 ? Math.min(r.actual / r.planned, 1) * 100 : r.actual > 0 ? 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="dash-progress" key={r.name}>
|
||||||
|
<div className="dash-progress-top">
|
||||||
|
<span className="ellipsis" style={{ color: r.color }}>●</span>
|
||||||
|
<span className="grow ellipsis">{r.name}</span>
|
||||||
|
<span className={`amount small ${r.over ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
|
||||||
|
<span className="muted small"> / {formatMoney(r.planned, data.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="budget-bar">
|
||||||
|
<div className={`budget-bar-fill${r.over ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncomeExpense() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = useData(() => api.cardIncomeExpense());
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
return (
|
||||||
|
<div className="dash-kpis">
|
||||||
|
<div className="dash-kpi">
|
||||||
|
<span className="muted small">{t("cards.income")}</span>
|
||||||
|
<strong className="amount pos">{formatMoney(data.income, data.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="dash-kpi">
|
||||||
|
<span className="muted small">{t("cards.expense")}</span>
|
||||||
|
<strong className="amount neg">{formatMoney(data.expense, data.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="dash-kpi">
|
||||||
|
<span className="muted small">{t("cards.net")}</span>
|
||||||
|
<strong className={`amount ${data.net < 0 ? "neg" : "pos"}`}>{formatMoney(data.net, data.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentTransactions() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatDate } = useSettings();
|
||||||
|
const data = useData(() => api.cardRecent(8));
|
||||||
|
if (!data) return <Loading />;
|
||||||
|
if (!data.transactions?.length) return <div className="dash-empty muted">{t("transactions.empty")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="dash-list">
|
||||||
|
{data.transactions.map((tx) => (
|
||||||
|
<div className="dash-line" key={tx.id}>
|
||||||
|
<IconChip name={tx.icon} color={tx.color} size={26} />
|
||||||
|
<div className="grow ellipsis">
|
||||||
|
<div className="ellipsis">{tx.title}</div>
|
||||||
|
<div className="muted small">{formatDate(tx.date)}</div>
|
||||||
|
</div>
|
||||||
|
<Money cents={tx.amount_cents} currency={tx.currency} baseCents={tx.amount_base_cents}
|
||||||
|
baseCurrency={data.currency} sign={tx.sign} showConverted={false} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße }.
|
||||||
|
export const KARTEN = {
|
||||||
|
"account-balances": { titleKey: "cards.balances", Comp: AccountBalances, w: 4, h: 5, minW: 3, minH: 3 },
|
||||||
|
"budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4 },
|
||||||
|
"net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3 },
|
||||||
|
"budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2 },
|
||||||
|
"income-expense": { titleKey: "cards.incomeExpense", Comp: IncomeExpense, w: 4, h: 2, minW: 2, minH: 2 },
|
||||||
|
"recent-transactions": { titleKey: "cards.recent", Comp: RecentTransactions, w: 4, h: 4, minW: 3, minH: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KARTEN_TYPEN = Object.keys(KARTEN);
|
||||||
@@ -371,3 +371,151 @@ tr.budget-total-row td {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 560;
|
font-weight: 560;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Dashboard-Grid ---------- */
|
||||||
|
.dash-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dash-add {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dash-add-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
min-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-add-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dash-add-item:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dash-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dash-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.dash-card-title {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 650;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.dash-grid.editing .dash-card-head {
|
||||||
|
cursor: move;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.dash-remove {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.dash-card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.dash-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.dash-line:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.dash-total {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding-top: var(--sp-2);
|
||||||
|
margin-top: 2px;
|
||||||
|
border-top: 2px solid var(--border-strong);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dash-loading, .dash-empty {
|
||||||
|
padding: var(--sp-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.dash-progress {
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.dash-progress-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dash-kpis {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.dash-kpi {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.dash-kpi strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* react-grid-layout: Platzhalter + Resize-Griff an das Theme angleichen. */
|
||||||
|
.react-grid-item.react-grid-placeholder {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.15;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
border-right-color: var(--border-strong);
|
||||||
|
border-bottom-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|||||||
+22
-1
@@ -76,7 +76,28 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Übersicht",
|
"title": "Übersicht",
|
||||||
"empty": "Noch keine Karten. Das Dashboard wird in einer späteren Phase ausgebaut."
|
"emptyCards": "Keine Karten vorhanden – über den Anordnen-Modus hinzufügen.",
|
||||||
|
"edit": "Anordnen",
|
||||||
|
"done": "Fertig",
|
||||||
|
"addCard": "Karte hinzufügen",
|
||||||
|
"reset": "Zurücksetzen",
|
||||||
|
"resetConfirm": "Dashboard auf das Standard-Layout zurücksetzen?"
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"balances": "Kontostände",
|
||||||
|
"budgetDonut": "Budget",
|
||||||
|
"netWorth": "Vermögensverlauf",
|
||||||
|
"budgetProgress": "Budget-Fortschritt",
|
||||||
|
"incomeExpense": "Einnahmen & Ausgaben",
|
||||||
|
"recent": "Letzte Buchungen",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"planned": "geplant",
|
||||||
|
"spent": "ausgegeben",
|
||||||
|
"assets": "Vermögen",
|
||||||
|
"income": "Einnahmen",
|
||||||
|
"expense": "Ausgaben",
|
||||||
|
"net": "Saldo",
|
||||||
|
"noBudget": "Noch kein Budget für diesen Monat."
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server nicht erreichbar.",
|
"unreachable": "Server nicht erreichbar.",
|
||||||
|
|||||||
+22
-1
@@ -76,7 +76,28 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
"empty": "No cards yet. The dashboard will be built out in a later phase."
|
"emptyCards": "No cards. Click “Arrange” and add some.",
|
||||||
|
"edit": "Arrange",
|
||||||
|
"done": "Done",
|
||||||
|
"addCard": "Add card",
|
||||||
|
"reset": "Reset",
|
||||||
|
"resetConfirm": "Reset the dashboard to the default layout?"
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"balances": "Account balances",
|
||||||
|
"budgetDonut": "Budget",
|
||||||
|
"netWorth": "Net worth",
|
||||||
|
"budgetProgress": "Budget progress",
|
||||||
|
"incomeExpense": "Income & expenses",
|
||||||
|
"recent": "Recent transactions",
|
||||||
|
"total": "Total",
|
||||||
|
"planned": "planned",
|
||||||
|
"spent": "spent",
|
||||||
|
"assets": "Net worth",
|
||||||
|
"income": "Income",
|
||||||
|
"expense": "Expenses",
|
||||||
|
"net": "Balance",
|
||||||
|
"noBudget": "No budget for this month yet."
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server unreachable.",
|
"unreachable": "Server unreachable.",
|
||||||
|
|||||||
+139
-6
@@ -1,18 +1,151 @@
|
|||||||
|
import { createElement, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 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() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("dashboard.title")}</h1>
|
<div className="page-head">
|
||||||
<div className="card">
|
<h1>{t("dashboard.title")}</h1>
|
||||||
<div className="empty-state">
|
<div className="dash-toolbar">
|
||||||
<Icon name="pie-chart" size={40} />
|
{edit && missing.length > 0 && (
|
||||||
<p>{t("dashboard.empty")}</p>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user