Kategorien ein-/ausklappbar, Badge-Darstellung in Tabellenzellen korrigiert

Kategorien:
- Oberkategorien lassen sich per Pfeil zuklappen; ihre Unterkategorien
  verschwinden dann aus der Liste. Ausgeblendet wird ueber die gesamte
  Vorfahrenkette, nicht nur eine Ebene, damit auch mehrstufige Baeume stimmen.
- Ist eine Kategorie zugeklappt, zeigt ein Badge, wie viele Eintraege darunter
  liegen - sonst waere nicht erkennbar, dass dort noch etwas ist.
- Zeilen ohne Unterkategorien bekommen einen Platzhalter, damit alle Namen
  buendig stehen.

Badges in Zellen:
- In der Karten-Ansicht verteilte das Raster mehrere Elemente einer Zelle auf
  zwei Rasterzellen - deshalb rutschte "eingebaut" unter die Beschriftung.
  Jetzt Flexfluss, alle Teile stehen hinter der Beschriftung.
- Einheiten-Tabelle: Name und "eingebaut" liegen in einer Zeile zusammen.

Geprueft: "npm run build" laeuft durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 08:46:39 +02:00
parent 546a6bfc90
commit 903392e953
3 changed files with 63 additions and 14 deletions

View File

@@ -17,6 +17,17 @@ export default function Categories() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "" }); const [form, setForm] = useState({ name: "", parent_id: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Eingeklappte Oberkategorien; ihre Unterkategorien werden ausgeblendet.
const [collapsed, setCollapsed] = useState(() => new Set());
function toggle(id) {
setCollapsed((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
}
async function load() { async function load() {
try { try {
@@ -75,6 +86,20 @@ export default function Categories() {
const tree = asTree(categories); const tree = asTree(categories);
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name])); const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
// Verschachtelung kann mehrstufig sein: eine Zeile ist versteckt, sobald
// irgendein Vorfahre eingeklappt ist nicht nur der direkte Elternteil.
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
const childCount = (id) => categories.filter((c) => c.parent_id === id).length;
function versteckt(id) {
let p = parentOf[id];
while (p != null) {
if (collapsed.has(p)) return true;
p = parentOf[p];
}
return false;
}
const sichtbar = tree.filter((c) => !versteckt(c.id));
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -101,12 +126,20 @@ export default function Categories() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tree.map((c) => ( {sichtbar.map((c) => {
const kinder = childCount(c.id);
const zu = collapsed.has(c.id);
return (
<tr key={c.id}> <tr key={c.id}>
<td data-label="Kategorie"> <td data-label="Kategorie">
<span style={{ display: "flex", alignItems: "center", gap: 8, <span className="cell-row" style={{ paddingLeft: c.depth * 22 }}>
paddingLeft: c.depth * 22 }}> {kinder > 0 ? (
{c.depth > 0 && <span className="muted"></span>} <button type="button" className="btn-icon tree-toggle"
onClick={() => toggle(c.id)} aria-expanded={!zu}
title={zu ? "Unterkategorien einblenden" : "Unterkategorien ausblenden"}>
<Icon name="chevronRight" size={14} className={zu ? "" : "rotated"} />
</button>
) : <span className="tree-spacer" />}
{isAdmin ? ( {isAdmin ? (
<input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }} <input defaultValue={c.name} style={{ marginTop: 0, minWidth: 150 }}
onBlur={(e) => { onBlur={(e) => {
@@ -114,8 +147,11 @@ export default function Categories() {
if (v && v !== c.name) patch(c, { name: v }); if (v && v !== c.name) patch(c, { name: v });
}} /> }} />
) : <span className="strong">{c.name}</span>} ) : <span className="strong">{c.name}</span>}
{zu && kinder > 0 && (
<span className="badge nowrap">{kinder} ausgeblendet</span>
)}
{c.depth > 0 && c.parent_id && nameById[c.parent_id] && ( {c.depth > 0 && c.parent_id && nameById[c.parent_id] && (
<span className="badge">in {nameById[c.parent_id]}</span> <span className="badge nowrap">in {nameById[c.parent_id]}</span>
)} )}
</span> </span>
</td> </td>
@@ -128,7 +164,8 @@ export default function Categories() {
)} )}
</td> </td>
</tr> </tr>
))} );
})}
{categories.length === 0 && ( {categories.length === 0 && (
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr> <tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr>
)} )}

View File

@@ -70,7 +70,12 @@ export default function Units() {
<tbody> <tbody>
{units.map((u) => ( {units.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td data-label="Name" className="strong">{u.name}{u.is_builtin && <span className="badge" style={{ marginLeft: 6 }}>eingebaut</span>}</td> <td data-label="Name" className="strong">
<span className="cell-row">
<span>{u.name}</span>
{u.is_builtin && <span className="badge nowrap">eingebaut</span>}
</span>
</td>
<td data-label="Art" className="muted">{KIND_LABEL[u.kind] || u.kind}</td> <td data-label="Art" className="muted">{KIND_LABEL[u.kind] || u.kind}</td>
<td data-label="Faktor" className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td> <td data-label="Faktor" className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td>
<td className="num"> <td className="num">

View File

@@ -269,6 +269,13 @@ td select { width: auto; min-width: 0; max-width: 100%; }
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass /* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */ dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
.tip { border-bottom: 1px dotted var(--border-strong); } .tip { border-bottom: 1px dotted var(--border-strong); }
/* Auf-/Zuklappen von Unterkategorien */
.tree-toggle { padding: 2px; flex: 0 0 auto; }
.tree-toggle .icon { transition: transform 0.12s ease; }
.tree-toggle .icon.rotated { transform: rotate(90deg); }
/* Platzhalter, damit Zeilen ohne Unterkategorien buendig zu denen mit stehen. */
.tree-spacer { display: inline-block; flex: 0 0 auto; width: 24px; }
/* In Zahlenspalten sitzt der Inhalt rechts, passend zur Ausrichtung der Spalte. */ /* In Zahlenspalten sitzt der Inhalt rechts, passend zur Ausrichtung der Spalte. */
.table td.num .cell-row { justify-content: flex-end; } .table td.num .cell-row { justify-content: flex-end; }
.table tbody tr:last-child td { border-bottom: none; } .table tbody tr:last-child td { border-bottom: none; }
@@ -363,18 +370,18 @@ td select { width: auto; min-width: 0; max-width: 100%; }
padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3); padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3);
background: var(--surface); background: var(--surface);
} }
/* Zwei feste Spalten statt "space-between": Beschriftung links, Wert direkt /* Beschriftung links, Wert direkt daneben. Bewusst Flex statt Raster: bei
daneben. Vorher wurde der Wert an den rechten Rand geschoben, wodurch einem Raster landet ein zweites Element in der Zelle (z.B. das Badge
Beschriftung und Wert weit auseinanderstanden und die Zeile unruhig wirkte. */ "eingebaut") in der naechsten Rasterzelle - also unter der Beschriftung.
Im Flexfluss reihen sich alle Teile hinter der Beschriftung auf. */
.table td { .table td {
border: none; padding: 4px 0; border: none; padding: 4px 0;
display: grid; display: flex; align-items: baseline;
grid-template-columns: minmax(84px, 34%) minmax(0, 1fr); gap: var(--sp-2); text-align: left; flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-3); text-align: left;
} }
.table td::before { .table td::before {
content: attr(data-label); content: attr(data-label);
flex: 0 0 84px;
color: var(--muted); font-size: 0.7rem; font-weight: 600; color: var(--muted); font-size: 0.7rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; text-transform: uppercase; letter-spacing: 0.05em;
} }