Web: Mindestbestaende in festen Spalten, Detail-Spalten verschiebbar

Die Eingabefelder auf der Mindestbestaende-Seite standen je Zeile woanders. Die
Zeile war ein .field-inline, und dort waechst sowohl der Name als auch das
Eingabefeld (.field-inline > input { flex: 1 1 auto }) - beide teilten sich den
Restplatz, also verschob ein laengerer Name das Feld nach rechts und machte es
schmaler. Jetzt ist die Zeile ein Raster mit festen Spalten (Name, Menge,
Einheit, Bestand, Ort, Loeschen); ohne Adminrechte bleiben die letzten beiden
Zellen leer, damit die Zeilen trotzdem fluchten. Schmale Fenster stellen den
Namen ueber die volle Breite.

Die "Details"-Spalte liess sich nicht verschieben: sie war fixed und hatte
zusaetzlich einen leeren Kopf - es gab also nichts zu greifen. Sie ist jetzt
frei und traegt einen sichtbaren Titel. Dasselbe fuer die gleichartigen
Link-Spalten in der Einzelstueck- und der Chargenliste ("Artikel",
"Aufteilen"). Sortieren bleibt dort aus, weil toggleSort ohne sortValue
ohnehin aussteigt. Bild- und Auswahlspalten bleiben fest - die haben keinen
sinnvollen Titel und gehoeren an den Anfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 10:36:48 +02:00
parent e00eef275c
commit 01c93d8b2c
5 changed files with 38 additions and 13 deletions

View File

@@ -139,12 +139,12 @@ export default function Charges() {
{ key: "created", header: "Eingelagert", width: 130, { key: "created", header: "Eingelagert", width: 130,
sortValue: (l) => new Date(l.created_at).getTime(), sortValue: (l) => new Date(l.created_at).getTime(),
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> }, render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
{ key: "split", header: "", label: "Aufteilen", fixed: true, width: 118, align: "num", { key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
render: (l) => (isAdmin ? ( render: (l) => (isAdmin ? (
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern" <button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button> onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
) : null) }, ) : null) },
{ key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num", { key: "details", header: "Artikel", label: "Artikel", width: 90, align: "num",
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> }, render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
]; ];

View File

@@ -88,7 +88,7 @@ export default function ItemList() {
render: (it) => preis(it) || "" }, render: (it) => preis(it) || "" },
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "", { key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
render: (it) => <span className="muted">{it.note || ""}</span> }, render: (it) => <span className="muted">{it.note || ""}</span> },
{ key: "open", header: "", label: "Produkt öffnen", fixed: true, width: 84, align: "num", { key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> }, render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
]; ];

View File

@@ -259,8 +259,8 @@ export default function MinStock() {
function zeile(r) { function zeile(r) {
const g = r.kind === "group" ? r.entity : null; const g = r.kind === "group" ? r.entity : null;
return ( return (
<div className="field-inline" key={r.key} style={{ marginBottom: 0, alignItems: "center" }}> <div className="min-zeile" key={r.key}>
<span className="grow" style={{ flex: "1 1 auto", minWidth: 0 }}> <span className="name">
<span className="strong">{r.name}</span> <span className="strong">{r.name}</span>
{g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>} {g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
{g?.child_ids?.length > 0 && ( {g?.child_ids?.length > 0 && (
@@ -272,21 +272,21 @@ export default function MinStock() {
</span> </span>
{isAdmin ? ( {isAdmin ? (
<input type="number" step="any" min="0" style={{ marginTop: 0, width: 100 }} <input type="number" step="any" min="0"
key={r.soll ?? "none"} defaultValue={r.soll ?? ""} key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
onBlur={(e) => saveSoll(r, e.target.value)} /> onBlur={(e) => saveSoll(r, e.target.value)} />
) : <span style={{ width: 100 }}>{fmt(r.soll)}</span>} ) : <span>{fmt(r.soll)}</span>}
{/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */} {/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */}
{isAdmin && r.kind === "product" && hasPkg(r.entity) ? ( {isAdmin && r.kind === "product" && hasPkg(r.entity) ? (
<select value={pkgMode(r.entity) ? "pkg" : "disp"} style={{ marginTop: 0, width: 130 }} <select value={pkgMode(r.entity) ? "pkg" : "disp"}
title="Einheit für den Mindestbestand umschalten" title="Einheit für den Mindestbestand umschalten"
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}> onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
<option value="disp">{dispLabel(r.entity)}</option> <option value="disp">{dispLabel(r.entity)}</option>
<option value="pkg">{r.entity.package_label || "Packung"}</option> <option value="pkg">{r.entity.package_label || "Packung"}</option>
</select> </select>
) : isAdmin && g && g.min_stock_unit_id ? ( ) : isAdmin && g && g.min_stock_unit_id ? (
<select value={grpPkgMode(g) ? "pkg" : "unit"} style={{ marginTop: 0, width: 130 }} <select value={grpPkgMode(g) ? "pkg" : "unit"}
title="Einheit für den Mindestbestand umschalten" title="Einheit für den Mindestbestand umschalten"
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@@ -299,16 +299,16 @@ export default function MinStock() {
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option> <option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
</select> </select>
) : ( ) : (
<span className="muted small" style={{ width: 130 }}>{r.unit || ""}</span> <span className="muted small">{r.unit || ""}</span>
)} )}
<span className="muted small" style={{ width: 130, textAlign: "right" }}> <span className="muted small bestand">
{r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""} {r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""}
</span> </span>
{isAdmin && ( {isAdmin && (
<> <>
<select value={vonId(r.locId)} style={{ marginTop: 0, width: 150 }} <select value={vonId(r.locId)}
title="Ort ändern der Mindestbestand wird verschoben" title="Ort ändern der Mindestbestand wird verschoben"
onChange={(e) => moveRow(r, e.target.value)}> onChange={(e) => moveRow(r, e.target.value)}>
<option value={UEBERALL}>Überall</option> <option value={UEBERALL}>Überall</option>
@@ -318,6 +318,9 @@ export default function MinStock() {
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button> onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
</> </>
)} )}
{/* Ohne Adminrechte bleiben die letzten beiden Spalten leer, damit die
Zeilen trotzdem fluchten. */}
{!isAdmin && (<><span /><span /></>)}
</div> </div>
); );
} }

View File

@@ -141,7 +141,9 @@ export default function Products({ fixedType = null }) {
sortValue: (p) => (p.updated_at ? new Date(p.updated_at).getTime() : 0), sortValue: (p) => (p.updated_at ? new Date(p.updated_at).getTime() : 0),
filterText: (p) => fmtDateTime(p.updated_at), filterText: (p) => fmtDateTime(p.updated_at),
render: (p) => <span className="muted nowrap">{fmtDateTime(p.updated_at)}</span> }, render: (p) => <span className="muted nowrap">{fmtDateTime(p.updated_at)}</span> },
{ key: "details", header: "", label: "Details", fixed: true, width: 84, align: "num", // Kein fixed: die Spalte soll sich verschieben lassen. Dafuer braucht sie
// einen sichtbaren Titel an einem leeren Kopf gibt es nichts zu greifen.
{ key: "details", header: "Details", label: "Details", width: 90, align: "num",
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> }, render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
].filter(Boolean); ].filter(Boolean);

View File

@@ -328,6 +328,26 @@ td .field-inline { margin-bottom: 0; flex-wrap: wrap; }
132px machte schmale Spalten (z.B. "Einheit") unnoetig breit. */ 132px machte schmale Spalten (z.B. "Einheit") unnoetig breit. */
td select { width: auto; min-width: 0; max-width: 100%; } td select { width: auto; min-width: 0; max-width: 100%; }
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */ /* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */
/* Mindestbestände: eine Zeile je Eintrag in FESTEN Spalten.
Vorher lag hier ein .field-inline dort wachsen Name und Eingabefeld beide
(.field-inline > input { flex: 1 1 auto }) und teilen sich den Restplatz.
Die Felder standen dadurch je nach Namenslänge woanders. */
.min-zeile {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px 150px 150px 160px 36px;
gap: var(--sp-2);
align-items: center;
}
.min-zeile > * { min-width: 0; }
.min-zeile input, .min-zeile select { margin-top: 0; width: 100%; min-width: 0; }
.min-zeile .bestand { text-align: right; }
/* Schmal: Name über die volle Breite, darunter die Felder nebeneinander. */
@media (max-width: 900px) {
.min-zeile { grid-template-columns: 1fr 1fr 1fr 36px; }
.min-zeile > .name { grid-column: 1 / -1; }
.min-zeile .bestand { text-align: left; }
}
.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; } .btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; } .btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }