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>
This commit is contained in:
@@ -99,47 +99,69 @@ def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _units(groups: list[CategoryGroup], by_category: bool):
|
||||||
|
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
|
||||||
|
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
|
||||||
|
for g in groups:
|
||||||
|
if by_category:
|
||||||
|
for c in g.categories:
|
||||||
|
if not c.is_archived:
|
||||||
|
yield (c.name, c.color, [c.id])
|
||||||
|
else:
|
||||||
|
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/budget-donut")
|
@router.get("/budget-donut")
|
||||||
def budget_donut(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
def budget_donut(
|
||||||
|
group_by: str = Query(default="group"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
label = _label(db, user)
|
label = _label(db, user)
|
||||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
groups = _expense_groups(db, user)
|
groups = _expense_groups(db, user)
|
||||||
|
by_category = group_by == "category"
|
||||||
|
|
||||||
segments = []
|
def collect(values: dict[int, int]):
|
||||||
total = 0
|
segs, tot = [], 0
|
||||||
for g in groups:
|
for name, color, ids in _units(groups, by_category):
|
||||||
val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
|
val = sum(values.get(i, 0) for i in ids)
|
||||||
if val > 0:
|
if val > 0:
|
||||||
segments.append({"name": g.name, "value": val, "color": g.color})
|
segs.append({"name": name, "value": val, "color": color})
|
||||||
total += val
|
tot += val
|
||||||
|
return segs, tot
|
||||||
|
|
||||||
|
segments, total = collect(planned)
|
||||||
mode = "planned"
|
mode = "planned"
|
||||||
if total == 0:
|
if total == 0:
|
||||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
|
||||||
for g in groups:
|
|
||||||
val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
|
|
||||||
if val > 0:
|
|
||||||
segments.append({"name": g.name, "value": val, "color": g.color})
|
|
||||||
total += val
|
|
||||||
mode = "actual"
|
mode = "actual"
|
||||||
return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label}
|
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
||||||
|
"label": label, "group_by": "category" if by_category else "group"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/budget-progress")
|
@router.get("/budget-progress")
|
||||||
def budget_progress(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
def budget_progress(
|
||||||
|
group_by: str = Query(default="group"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
base = base_currency(db, user)
|
base = base_currency(db, user)
|
||||||
label = _label(db, user)
|
label = _label(db, user)
|
||||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||||
planned = budget.effective_planned(db, user, label)
|
planned = budget.effective_planned(db, user, label)
|
||||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||||||
|
by_category = group_by == "category"
|
||||||
rows = []
|
rows = []
|
||||||
for g in _expense_groups(db, user):
|
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
||||||
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
|
gp = sum(planned.get(i, 0) for i in ids)
|
||||||
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
|
ga = sum(actual.get(i, 0) for i in ids)
|
||||||
if gp > 0 or ga > 0:
|
if gp > 0 or ga > 0:
|
||||||
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
|
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
|
||||||
return {"rows": rows, "currency": base, "label": label}
|
return {"rows": rows, "currency": base, "label": label,
|
||||||
|
"group_by": "category" if by_category else "group"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/income-expense")
|
@router.get("/income-expense")
|
||||||
|
|||||||
+2
-2
@@ -222,8 +222,8 @@ export const api = {
|
|||||||
dashboardLayout: () => request("/dashboard/layout"),
|
dashboardLayout: () => request("/dashboard/layout"),
|
||||||
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
|
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
|
||||||
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
||||||
cardBudgetDonut: () => request("/dashboard/budget-donut"),
|
cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||||
cardBudgetProgress: () => request("/dashboard/budget-progress"),
|
cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||||
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
||||||
cardBalances: () => request("/dashboard/balances"),
|
cardBalances: () => request("/dashboard/balances"),
|
||||||
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
|
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { api } from "../api";
|
|||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import IconChip from "../components/IconChip";
|
import IconChip from "../components/IconChip";
|
||||||
import Money from "../components/Money";
|
import Money from "../components/Money";
|
||||||
import { formatMoney } from "../format";
|
import { formatMoney, formatAmount } from "../format";
|
||||||
import { tokenFarbe } from "./palette";
|
import { tokenFarbe } from "./palette";
|
||||||
import Donut from "./charts/Donut";
|
import Donut from "./charts/Donut";
|
||||||
import Line from "./charts/Line";
|
import Line from "./charts/Line";
|
||||||
@@ -53,9 +53,10 @@ function AccountBalances() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetDonut() {
|
function BudgetDonut({ props }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const data = useData(() => api.cardBudgetDonut());
|
const groupBy = props?.group_by || "group";
|
||||||
|
const data = useData(() => api.cardBudgetDonut(groupBy));
|
||||||
if (!data) return <Loading />;
|
if (!data) return <Loading />;
|
||||||
if (!data.segments?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
|
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");
|
const label = data.mode === "planned" ? t("cards.planned") : t("cards.spent");
|
||||||
@@ -68,12 +69,17 @@ function NetWorth() {
|
|||||||
if (!data) return <Loading />;
|
if (!data) return <Loading />;
|
||||||
const labels = (data.labels || []).map(shortDate);
|
const labels = (data.labels || []).map(shortDate);
|
||||||
const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
|
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)} />;
|
return (
|
||||||
|
<Line labels={labels} serien={serien}
|
||||||
|
fmt={(v) => formatMoney(v, data.currency)}
|
||||||
|
achsenFmt={(v) => formatAmount(v, data.currency)} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BudgetProgress() {
|
function BudgetProgress({ props }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const data = useData(() => api.cardBudgetProgress());
|
const groupBy = props?.group_by || "group";
|
||||||
|
const data = useData(() => api.cardBudgetProgress(groupBy));
|
||||||
if (!data) return <Loading />;
|
if (!data) return <Loading />;
|
||||||
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
|
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
|
||||||
return (
|
return (
|
||||||
@@ -143,12 +149,22 @@ function RecentTransactions() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße }.
|
// Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt.
|
||||||
|
const AUFSCHLUESSELUNG = {
|
||||||
|
key: "group_by", labelKey: "cards.breakdown", def: "group",
|
||||||
|
choices: [
|
||||||
|
{ v: "group", labelKey: "cards.byGroup" },
|
||||||
|
{ v: "category", labelKey: "cards.byCategory" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße,
|
||||||
|
// optional `options` (im Anordnen-Modus einstellbare Karten-Optionen) }.
|
||||||
export const KARTEN = {
|
export const KARTEN = {
|
||||||
"account-balances": { titleKey: "cards.balances", Comp: AccountBalances, w: 4, h: 5, minW: 3, minH: 3 },
|
"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 },
|
"budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4, options: [AUFSCHLUESSELUNG] },
|
||||||
"net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3 },
|
"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 },
|
"budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2, options: [AUFSCHLUESSELUNG] },
|
||||||
"income-expense": { titleKey: "cards.incomeExpense", Comp: IncomeExpense, w: 4, h: 2, minW: 2, 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 },
|
"recent-transactions": { titleKey: "cards.recent", Comp: RecentTransactions, w: 4, h: 4, minW: 3, minH: 3 },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
|
|||||||
});
|
});
|
||||||
const gezeigt = aktiv ?? null;
|
const gezeigt = aktiv ?? null;
|
||||||
|
|
||||||
|
// Mittelbetrag an die Stringlänge anpassen, damit ein 4-stelliger Betrag
|
||||||
|
// (z. B. „CHF 3'865.00") nicht in den Ring läuft.
|
||||||
|
const zentralText = fmt(gezeigt ? gezeigt.value : summe);
|
||||||
|
const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1))));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-donut">
|
<div className="chart-donut">
|
||||||
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
|
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
|
||||||
@@ -40,8 +45,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
|
|||||||
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
|
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value"
|
||||||
{fmt(gezeigt ? gezeigt.value : summe)}
|
style={{ fontSize: wertGroesse }}>
|
||||||
|
{zentralText}
|
||||||
</text>
|
</text>
|
||||||
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
|
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
|
||||||
{gezeigt ? gezeigt.name : gesamtLabel}
|
{gezeigt ? gezeigt.name : gesamtLabel}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import useGroesse from "./useGroesse";
|
|||||||
// Liniendiagramm über die Zeit.
|
// Liniendiagramm über die Zeit.
|
||||||
// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }]
|
// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }]
|
||||||
// fmt: Werteformatierer.
|
// fmt: Werteformatierer.
|
||||||
export default function Line({ labels = [], serien = [], fmt = (v) => String(v) }) {
|
export default function Line({ labels = [], serien = [], fmt = (v) => String(v), achsenFmt }) {
|
||||||
|
const yFmt = achsenFmt || fmt; // Achsen-Ticks dürfen schmaler sein (ohne Währungssymbol)
|
||||||
const wirt = useRef(null);
|
const wirt = useRef(null);
|
||||||
const plot = useRef(null);
|
const plot = useRef(null);
|
||||||
const { breite, hoehe } = useGroesse(plot);
|
const { breite, hoehe } = useGroesse(plot);
|
||||||
@@ -18,7 +19,7 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v)
|
|||||||
return <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
|
return <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rand = { oben: 12, rechts: 12, unten: 24, links: 52 };
|
const rand = { oben: 12, rechts: 12, unten: 24, links: 58 };
|
||||||
const w = Math.max(breite, 240);
|
const w = Math.max(breite, 240);
|
||||||
const h = Math.max(hoehe, 120);
|
const h = Math.max(hoehe, 120);
|
||||||
const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
|
const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
|
||||||
@@ -71,7 +72,7 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v)
|
|||||||
{gitter.map((g) => (
|
{gitter.map((g) => (
|
||||||
<g key={g.wert}>
|
<g key={g.wert}>
|
||||||
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y} stroke={gitterFarbe} strokeWidth="1" />
|
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y} stroke={gitterFarbe} strokeWidth="1" />
|
||||||
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
|
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{yFmt(g.wert)}</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{achse.map(({ l, i }) => (
|
{achse.map(({ l, i }) => (
|
||||||
|
|||||||
@@ -420,6 +420,32 @@ tr.budget-total-row td {
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Karten-Optionen im Anordnen-Modus (z. B. Gruppen vs. Kategorien). */
|
||||||
|
.dash-config-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.dash-config-opt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 140px;
|
||||||
|
}
|
||||||
|
.seg.sm {
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.seg.sm .seg-btn {
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dash-grid {
|
.dash-grid {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,10 @@
|
|||||||
"income": "Einnahmen",
|
"income": "Einnahmen",
|
||||||
"expense": "Ausgaben",
|
"expense": "Ausgaben",
|
||||||
"net": "Saldo",
|
"net": "Saldo",
|
||||||
"noBudget": "Noch kein Budget für diesen Monat."
|
"noBudget": "Noch kein Budget für diesen Monat.",
|
||||||
|
"breakdown": "Aufschlüsselung",
|
||||||
|
"byGroup": "Gruppen",
|
||||||
|
"byCategory": "Kategorien"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server nicht erreichbar.",
|
"unreachable": "Server nicht erreichbar.",
|
||||||
|
|||||||
@@ -122,7 +122,10 @@
|
|||||||
"income": "Income",
|
"income": "Income",
|
||||||
"expense": "Expenses",
|
"expense": "Expenses",
|
||||||
"net": "Balance",
|
"net": "Balance",
|
||||||
"noBudget": "No budget for this month yet."
|
"noBudget": "No budget for this month yet.",
|
||||||
|
"breakdown": "Breakdown",
|
||||||
|
"byGroup": "Groups",
|
||||||
|
"byCategory": "Categories"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"unreachable": "Server unreachable.",
|
"unreachable": "Server unreachable.",
|
||||||
|
|||||||
@@ -43,12 +43,19 @@ export default function Dashboard() {
|
|||||||
async function persist(next) {
|
async function persist(next) {
|
||||||
setItems(next);
|
setItems(next);
|
||||||
try {
|
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 })));
|
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) {
|
} catch (e) {
|
||||||
toast(e.message, "error");
|
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) {
|
function addCard(type) {
|
||||||
const def = KARTEN[type];
|
const def = KARTEN[type];
|
||||||
const maxY = items.reduce((m, it) => Math.max(m, it.y + it.h), 0);
|
const maxY = items.reduce((m, it) => Math.max(m, it.y + it.h), 0);
|
||||||
@@ -140,7 +147,30 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dash-card-body">{def ? createElement(def.Comp) : null}</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user