Tabellen werden bei wenig Platz zu Karten statt seitwaerts zu scrollen

Waagerechtes Scrollen in einem Block war die falsche Antwort auf zu wenig
Platz - man sieht nicht, dass es weitergeht, und auf dem Trackpad ist es
muehsam. Das Scrollen ist ersatzlos weg.

Stattdessen stellt sich jede Tabelle unter 700px verfuegbarer Breite auf
Karten um: eine Karte je Zeile, jede Angabe mit ihrer Beschriftung davor.
Die Beschriftung kommt aus data-label an der Zelle, die Kopfzeile wird
ausgeblendet. Zellen ohne Beschriftung (Vorschaubild, Aktionsknoepfe) bleiben
ohne eigene Zeile.

Ausgeloest wird das per Container-Abfrage, nicht per Bildschirmbreite. Damit
richtet sich die Darstellung nach dem Platz, den die Tabelle wirklich hat -
sie stellt sich also auch um, wenn das Fenster nur die halbe Bildschirmbreite
einnimmt oder ein Block danebensteht.

Dazu die Aufteilung neben dem Block: Die Tabelle hat mehr Spalten als der
Block und bekommt jetzt den groesseren Anteil (1,5 zu 1, Block mindestens
340px). Vorher waren beide gleich breit, wodurch die Tabelle unnoetig frueh in
die Kartenansicht gerutscht waere, obwohl daneben Platz frei war.

Durchgerechnet: Bei 1920 voller Breite bleibt es eine Tabelle (802px) mit
komfortablem Block daneben (534px). Bei halber Bildschirmbreite stapelt sich
die Seite und die Tabelle wird zur Karte. In keinem Fall wird etwas
abgeschnitten und nirgends muss seitwaerts gescrollt werden.

Lange Zeichenketten in Zellen brechen jetzt um (overflow-wrap), da kein
Scrollen mehr auffaengt, was hinauslaeuft.

Dabei aufgefallen: In der Produkttabelle stand nach dem Ergaenzen der
Kategorie-Spalte noch colSpan={7} statt 8 - die Zeile "Keine Produkte
gefunden" ueberspannte die Tabelle nicht mehr ganz. Alle colSpan-Angaben sind
jetzt gegen die Spaltenzahl gegengeprueft.

Getestet: Web-Build laeuft durch, Spaltenzahl und colSpan stimmen in allen
fuenf betroffenen Tabellen ueberein, die Umbruchpunkte sind durchgerechnet.
Die Darstellung im Browser habe ich nicht selbst angesehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 22:00:44 +02:00
parent 8f549636a8
commit 22b6cdffc5
10 changed files with 94 additions and 52 deletions

View File

@@ -103,7 +103,7 @@ export default function Categories() {
<tbody>
{tree.map((c) => (
<tr key={c.id}>
<td>
<td data-label="Kategorie">
<span style={{ display: "flex", alignItems: "center", gap: 8,
paddingLeft: c.depth * 22 }}>
{c.depth > 0 && <span className="muted"></span>}
@@ -119,7 +119,7 @@ export default function Categories() {
)}
</span>
</td>
<td className="num muted">{c.product_count}</td>
<td data-label="Artikel" className="num muted">{c.product_count}</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">

View File

@@ -84,15 +84,15 @@ export default function Dashboard() {
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
<td>{it.product_name}</td>
<td className="num">
<td data-label="Produkt">{it.product_name}</td>
<td data-label="Menge" className="num">
{articlePrimary(it)}
{articleSecondary(it) && (
<div className="muted small">{articleSecondary(it)}</div>
)}
</td>
<td>{formatBestBefore(it.best_before, it.best_before_precision)}</td>
<td>
<td data-label="MHD">{formatBestBefore(it.best_before, it.best_before_precision)}</td>
<td data-label="Frist">
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
{relativeExpiry(it.days_left)}
</span>
@@ -120,16 +120,16 @@ export default function Dashboard() {
<tbody>
{groupShopping.map((it) => (
<tr key={`g${it.group_id}`}>
<td><span className="badge accent">Gruppe</span> {it.name}</td>
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr>
))}
{shopping.map((it) => (
<tr key={`p${it.product_id}`}>
<td>{it.name}</td>
<td className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
<td className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
<td data-label="Bedarf">{it.name}</td>
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
</tr>
))}
</tbody>

View File

@@ -104,7 +104,7 @@ export default function Groups() {
const low = g.min_stock != null && g.stock < g.min_stock;
return (
<tr key={g.id}>
<td>
<td data-label="Gruppe">
{isAdmin ? (
<input defaultValue={g.name} style={{ marginTop: 0, minWidth: 130 }}
onBlur={(e) => {
@@ -114,9 +114,9 @@ export default function Groups() {
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{g.product_count}</td>
<td className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td>
<td data-label="Produkte" className="num muted">{g.product_count}</td>
<td data-label="Bestand" className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td data-label="Mindestbestand">
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ marginTop: 0, minWidth: 90 }}
@@ -128,7 +128,7 @@ export default function Groups() {
}} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")}
</td>
<td>
<td data-label="Einheit">
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, {
@@ -139,7 +139,7 @@ export default function Groups() {
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td className="num">
<td data-label="EAN-Codes" className="num">
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{/* Beide Herkünfte zählen, sonst steht bei einer Gruppe
mit Artikeln irreführend eine 0. */}

View File

@@ -38,21 +38,21 @@ export default function History() {
<tbody>
{movements.map((m) => (
<tr key={m.id}>
<td className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
<td>
<td data-label="Zeitpunkt" className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
<td data-label="Aktion">
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
<Icon name={m.type === "in" ? "checkin" : m.type === "out" ? "checkout" : "edit"} size={13} />
{TYPE_LABEL[m.type] || m.type}
</span>
</td>
<td>{m.product_name}</td>
<td className="num">
<td data-label="Produkt">{m.product_name}</td>
<td data-label="Menge" className="num">
{articlePrimary(m)}
{articleSecondary(m) && (
<div className="muted small">{articleSecondary(m)}</div>
)}
</td>
<td className="muted">{m.username || ""}</td>
<td data-label="Benutzer" className="muted">{m.username || ""}</td>
</tr>
))}
{movements.length === 0 && (

View File

@@ -541,7 +541,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
const editing = editId === l.id;
return (
<tr key={l.id} className={cls}>
<td className="num">
<td data-label="Menge" className="num">
{editing ? (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<input type="number" step="any" min="0" value={draft.quantity}
@@ -566,7 +566,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
</>
)}
</td>
<td>
<td data-label="MHD">
{editing ? (
<input type={draft.precision === "month" ? "month" : "date"}
value={draft.best_before}
@@ -584,7 +584,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
</>
)}
</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
<td data-label="Eingelagert" className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
<td className="num">
{isAdmin && (editing ? (
<div className="btn-pair">

View File

@@ -99,7 +99,7 @@ export default function Products() {
? <img className="thumb" src={p.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
</td>
<td className="strong">
<td data-label="Name" className="strong">
{p.name}
{p.expired_count > 0 && (
<span className="badge danger" style={{ marginLeft: 6 }}>
@@ -107,18 +107,18 @@ export default function Products() {
</span>
)}
</td>
<td className="muted">{p.brand || ""}</td>
<td className="muted">{p.category_name || ""}</td>
<td>
<td data-label="Marke" className="muted">{p.brand || ""}</td>
<td data-label="Kategorie" className="muted">{p.category_name || ""}</td>
<td data-label="Einheit">
{p.unit_name || unitShort(p.base_unit)}
{p.package_size
? ` · ${p.package_label || "Packung"} ${fmt(p.package_size)} ${unitShort(p.base_unit)}`
: ""}
</td>
<td className="num">
<td data-label="Bestand" className="num">
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
</td>
<td className="num">
<td data-label="Einheiten" className="num">
{fmt(p.stock / unitCount(p))}
{p.min_stock != null ? ` / ${fmt(p.min_stock / unitCount(p))}` : ""}{" "}
{countLabel(p)}
@@ -129,7 +129,7 @@ export default function Products() {
);
})}
{products.length === 0 && (
<tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
<tr><td colSpan={8} className="empty">Keine Produkte gefunden.</td></tr>
)}
</tbody>
</table>

View File

@@ -145,7 +145,7 @@ export default function Transfer() {
<section className="card">
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
<div className="table-wrap">
<table className="table">
<table className="table table-compact">
<thead><tr><th>Spalte</th><th>Bedeutung</th></tr></thead>
<tbody>
<tr><td className="strong">barcode</td><td>optional; dient zum Wiedererkennen vorhandener Produkte</td></tr>

View File

@@ -70,9 +70,9 @@ export default function Units() {
<tbody>
{units.map((u) => (
<tr key={u.id}>
<td className="strong">{u.name}{u.is_builtin && <span className="badge" style={{ marginLeft: 6 }}>eingebaut</span>}</td>
<td className="muted">{KIND_LABEL[u.kind] || u.kind}</td>
<td className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td>
<td data-label="Name" className="strong">{u.name}{u.is_builtin && <span className="badge" style={{ marginLeft: 6 }}>eingebaut</span>}</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 className="num">
{!u.is_builtin && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">

View File

@@ -79,10 +79,10 @@ export default function Users() {
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td className="strong">
<td data-label="Name" className="strong">
{u.username}{u.id === me.id && <span className="muted"> (du)</span>}
</td>
<td>
<td data-label="Rolle">
<select value={u.role} onChange={(e) => changeRole(u, e.target.value)}
disabled={u.id === me.id}>
<option value="user">Benutzer</option>

View File

@@ -159,15 +159,16 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
gap: var(--sp-4);
}
/* Ohne min-width:0 schrumpfen Grid-Kinder nie unter ihre Inhaltsbreite. Genau
daran scheiterte das overflow-x der Tabelle: Sie wurde abgeschnitten,
statt zu scrollen. */
/* Ohne min-width:0 schrumpfen Grid-Kinder nie unter ihre Inhaltsbreite -
dadurch lief die Tabelle aus ihrer Karte heraus und wurde abgeschnitten. */
.grid-2 > * { min-width: 0; }
.form-narrow { max-width: 600px; }
/* Seiten mit einem inhaltsreichen Block daneben (EAN-Codes) brauchen mehr
Mindestbreite und stapeln deshalb frueher. */
.grid-2.wide-aside {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr));
/* Tabelle plus Block daneben: Die Tabelle hat mehr Spalten und bekommt
deshalb den groesseren Anteil - sonst rutscht sie unnoetig frueh in die
Kartenansicht, obwohl daneben Platz frei waere. */
.grid-2.wide-aside { grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr); }
@media (max-width: 1000px) {
.grid-2.wide-aside { grid-template-columns: minmax(0, 1fr); }
}
/* ---------- Forms ---------- */
@@ -235,11 +236,15 @@ td select { width: auto; min-width: 132px; max-width: 100%; }
.link-btn:hover { text-decoration: underline; }
/* ---------- Tables ---------- */
.table-wrap { overflow-x: auto; max-width: 100%; }
/* min-width: Lieber waagerecht scrollen als Spalten so weit quetschen, dass
Text abgeschnitten wird. */
.table { width: 100%; min-width: 640px; border-collapse: collapse; font-size: 0.875rem; }
.table th, .table td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border); }
/* container-type: Die Tabelle richtet sich nach dem Platz, den sie wirklich
hat - nicht nach der Bildschirmbreite. So stellt sie sich auch um, wenn das
Fenster nur die halbe Bildschirmbreite einnimmt oder ein Block danebensteht. */
.table-wrap { max-width: 100%; container-type: inline-size; }
.table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.table th, .table td {
text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
}
.table thead th {
color: var(--muted); font-weight: 600; font-size: 0.72rem;
text-transform: uppercase; letter-spacing: 0.04em;
@@ -324,6 +329,43 @@ td select { width: auto; min-width: 132px; max-width: 100%; }
.precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; }
.precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; }
/* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */
/* Wird es eng, ist eine Tabelle das falsche Mittel: Spalten quetschen sich,
Text wird abgeschnitten, und waagerechtes Scrollen in einem Block ist
unbrauchbar. Stattdessen wird jede Zeile zu einer Karte, in der jede Angabe
ihre Beschriftung aus data-label bekommt. */
@container (max-width: 700px) {
.table thead { display: none; }
.table, .table tbody, .table tr, .table td { display: block; width: 100%; }
.table tr {
border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3);
background: var(--surface);
}
.table td {
border: none; padding: 5px 0;
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); text-align: left;
}
.table td::before {
content: attr(data-label);
flex: 0 0 auto;
color: var(--muted); font-size: 0.7rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
}
/* Zellen ohne Beschriftung (Bild, Aktionen) brauchen keine Zeile fuer sich. */
.table td:not([data-label])::before { content: none; }
.table td:not([data-label]) { justify-content: flex-end; }
.table td.num { text-align: left; }
.table td input, .table td select { width: auto; max-width: 60%; margin-top: 0; }
.table .empty { text-align: center; }
/* Reine Nachschlagelisten (Begriff -> Erklaerung) bleiben zweispaltig,
dort waere eine Karte je Zeile nur Laerm. */
.table.table-compact, .table.table-compact tbody, .table.table-compact tr,
.table.table-compact td { display: revert; }
.table.table-compact thead { display: table-header-group; }
.table.table-compact td::before { content: none; }
}
/* ---------- EAN-Code-Zeile ---------- */
.code-row { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; flex-wrap: wrap; }
.code-row code { flex: 0 0 auto; }