Verlauf: Bewegungsart als Sinnbild statt als Wort

"Eingelagert"/"Ausgelagert"/"Korrektur" standen als Textbadge in einer eigenen
Spalte - in der schmalen Ansicht als dritte beschriftete Zeile je Eintrag. Jetzt
eine getoente Kachel mit Icon: Kiste mit Pfeil hinein, Kiste mit Pfeil heraus,
Stapel mit Stift (aus open-box (1).svg, open-box.svg, edit-server.svg). Die
Farbe traegt dieselbe Bedeutung wie vorher das Badge - gruen rein, orange raus,
neutral Korrektur; Titel und aria-label behalten das Wort.

Die Dashboard-Karte bekommt dabei das Layout aus der Skizze: Kachel links,
Zeitpunkt ueber Produkt, ohne Tabelle. Die Verlaufs-SEITE bleibt eine Tabelle -
dort stehen noch Menge und Benutzer -, dort weicht nur das Wort der Kachel.

Die Icon-Komponente kann jetzt auch GEFUELLTE Grafiken mit eigenem viewBox; die
vorhandenen sind Strichzeichnungen (fill:none, stroke:currentColor) und
vertragen sich damit nicht. Die Pfaddaten sind per Skript aus den SVGs
uebernommen, nicht abgetippt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 12:38:05 +02:00
parent e9a2b70728
commit 9a2dc3a482
8 changed files with 136 additions and 30 deletions

View File

@@ -0,0 +1,24 @@
import Icon from "./Icon";
/**
* Die Art einer Bestandsbewegung als Sinnbild statt als Wort.
*
* Kiste mit Pfeil hinein (Einlagern), Kiste mit Pfeil heraus (Auslagern),
* Stapel mit Stift (Korrektur). Die Kachel traegt dieselbe Bedeutung wie
* frueher die Farbe des Badges gruen rein, orange raus, neutral Korrektur.
*
* Titel und aria-label behalten das Wort: fuer Screenreader und fuer alle, die
* ein Sinnbild nicht auf Anhieb lesen.
*/
const LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
const ICON = { in: "einlagern", out: "auslagern", adjust: "korrektur" };
const TON = { in: "ein", out: "aus", adjust: "" };
export default function BewegungIcon({ typ, size = 18 }) {
const wort = LABEL[typ] || typ;
return (
<span className={`bewegung-icon ${TON[typ] || ""}`} title={wort} aria-label={wort} role="img">
<Icon name={ICON[typ] || "package"} size={size} />
</span>
);
}

View File

@@ -142,7 +142,69 @@ const PATHS = {
),
};
// Gefuellte Icons mit EIGENEM viewBox. Die uebrigen hier sind Strichzeichnungen
// (fill:none, stroke:currentColor); diese kommen als fertige Grafik und muessen
// deshalb getrennt gehalten werden.
const GEFUELLT = {
// Kiste mit Pfeil HINEIN aus open-box (1).svg
einlagern: {
viewBox: "0 0 24 24",
node: (
<>
<path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z" />
<path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z" />
<path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.439-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z" />
<path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.286-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z" />
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z" />
<path d="m12 8c-.161 0-.322-.052-.457-.155l-3.25-2.5c-.328-.253-.39-.724-.138-1.052.253-.328.724-.391 1.052-.138l2.793 2.149 2.793-2.148c.328-.253.799-.19 1.052.138.252.328.19.799-.138 1.052l-3.25 2.5c-.135.102-.296.154-.457.154z" />
</>
),
},
// Kiste mit Pfeil HERAUS aus open-box.svg
auslagern: {
viewBox: "0 0 24 24",
node: (
<>
<path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z" />
<path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z" />
<path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.44-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z" />
<path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.288-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z" />
<path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z" />
<path d="m15.249 4c-.159 0-.32-.051-.456-.155l-2.793-2.149-2.793 2.149c-.328.252-.799.19-1.052-.138-.252-.328-.19-.799.138-1.052l3.25-2.5c.27-.207.645-.207.914 0l3.25 2.5c.328.253.39.724.138 1.052-.149.192-.37.293-.596.293z" />
</>
),
},
// Stapel mit Stift aus edit-server.svg
korrektur: {
viewBox: "0 0 64 64",
node: (
<>
<path d="m64 39.97-8.58-8.58-18.48 18.47-3.86 12.36 12.44-3.76zm-25.83 17.21 1.73-5.53 15.52-15.52 3.84 3.84-15.51 15.53z" />
<path d="m26.74 34.89c10.1 0 20.29-2.02 24.6-5.92h2.14v-18.79c0-6.67-13.45-10.16-26.73-10.16-13.3.01-26.75 3.5-26.75 10.16v43.62c0 6.67 13.45 10.17 26.74 10.17v-3.35c-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.28 14.14 4.97 23.39 4.97 1.9 0 3.72-.07 5.42-.2l-.26-3.34c-1.61.12-3.35.19-5.16.19-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.29 14.14 4.99 23.39 4.99zm0-31.51c14.28 0 23.38 4.03 23.38 6.81s-9.11 6.81-23.38 6.81-23.39-4.04-23.39-6.82c0-2.77 9.11-6.8 23.39-6.8zm-23.39 12c.02.01.04.02.06.04.28.19.57.37.88.55.03.02.06.03.08.05.29.17.6.33.91.48.05.03.1.05.16.08 5.14 2.5 13.25 3.78 21.3 3.78s16.16-1.28 21.3-3.78c.05-.03.11-.05.16-.08.32-.16.62-.32.91-.48.03-.01.05-.03.08-.04.31-.18.6-.36.88-.55.02-.01.04-.02.06-.04v9.35c0 2.78-9.11 6.81-23.38 6.81s-23.4-4.05-23.4-6.82z" />
</>
),
},
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
const gefuellt = GEFUELLT[name];
if (gefuellt) {
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox={gefuellt.viewBox}
fill="currentColor"
stroke="none"
aria-hidden="true"
focusable="false"
>
{gefuellt.node}
</svg>
);
}
const path = PATHS[name];
if (!path) return null;
return (

View File

@@ -4,6 +4,7 @@ import { api } from "../api";
import Icon from "../components/Icon";
import { CardSkeleton } from "../components/Skeleton";
import ShoppingNeedText from "../components/ShoppingNeedText";
import BewegungIcon from "../components/BewegungIcon";
import { useSettings } from "../settings";
import {
articlePrimary, articleSecondary, fmt, relativeExpiry,
@@ -393,33 +394,29 @@ function KarteEinkaufsliste({ props: karteProps }) {
);
}
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
function KarteBewegungen() {
const { daten, fehler } = useDaten(() => api.listMovements(20));
return (
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
<div className="table-wrap">
<table className="table">
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
<tbody>
{(daten || []).map((m) => (
<tr key={m.id}>
<td data-label="Zeitpunkt" className="muted">
{new Date(m.created_at).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</td>
<td data-label="Aktion">
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
{BEWEGUNG_LABEL[m.type] || m.type}
</span>
</td>
<td data-label="Produkt">{m.product_name}</td>
</tr>
))}
</tbody>
</table>
{/* Kachel links, Zeitpunkt ueber Produkt. Die Bewegungsart trug frueher
eine eigene Spalte mit dem Wort „Ausgelagert" als Sinnbild kostet
sie keine Zeile mehr und bleibt auf einen Blick erkennbar. */}
<div className="bewegung-liste">
{(daten || []).map((m) => (
<div className="bewegung-zeile" key={m.id}>
<BewegungIcon typ={m.type} />
<div className="text">
<div className="zeit">
{new Date(m.created_at).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</div>
<div className="produkt">{m.product_name}</div>
</div>
</div>
))}
</div>
</Zustand>
);

View File

@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import BewegungIcon from "../components/BewegungIcon";
import { articlePrimary, articleSecondary } from "../units";
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
export default function History() {
const [movements, setMovements] = useState([]);
const [error, setError] = useState(null);
@@ -39,12 +38,10 @@ export default function History() {
{movements.map((m) => (
<tr key={m.id}>
<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>
{/* Nur das Sinnbild die Tabelle hat noch Menge und
Benutzer zu zeigen, und das Wort stand ohnehin dreimal
untereinander. Titel und aria-label tragen es weiter. */}
<td data-label="Aktion"><BewegungIcon typ={m.type} /></td>
<td data-label="Produkt">{m.product_name}</td>
<td data-label="Menge" className="num">
{articlePrimary(m)}

View File

@@ -487,6 +487,28 @@ td select { width: auto; min-width: 0; max-width: 100%; }
background: var(--surface-2); color: var(--muted);
}
.badge.accent { background: var(--accent-soft); color: var(--accent); }
/* Verlauf: die Bewegungsart als Sinnbild statt als Wort. Ein gefuelltes Icon
in einer getoenten Kachel die Farbe traegt dieselbe Bedeutung wie vorher
das Badge (gruen rein, orange raus, neutral Korrektur). */
.bewegung-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px; border-radius: 10px; flex: 0 0 auto;
background: var(--accent-soft); color: var(--accent);
}
.bewegung-icon.ein { background: #e6f4ec; color: var(--ok); }
.bewegung-icon.aus { background: var(--warn-soft); color: var(--warn); }
/* Eine Bewegung als Zeile: Kachel links, Zeitpunkt ueber Produkt. */
.bewegung-liste { display: flex; flex-direction: column; }
.bewegung-zeile {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
}
.bewegung-zeile:last-child { border-bottom: 0; }
.bewegung-zeile .text { min-width: 0; }
.bewegung-zeile .zeit { color: var(--muted); font-size: 0.82rem; }
.bewegung-zeile .produkt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.ok { background: #e6f4ec; color: var(--ok); }
@@ -996,6 +1018,7 @@ select.zeitraum:hover { color: var(--text); }
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.badge.ok { background: #14301f; }
.bewegung-icon.ein { background: #14301f; }
.alert.ok { background: #14301f; border-color: #21503a; }
}
:root[data-theme="dark"] {