Web: Mindestbestand-Ort per Dropdown verschieben + OFF-Panel-Regression behoben

MinStock: die ORT-Spalte ist jetzt ein Dropdown (Gesamt/Lagerort). Auswahl
verschiebt den Bedarf (moveRow) statt ihn zu duplizieren - mit Rueckfrage, falls
der Zielort schon belegt ist. Produktwerte werden dabei umgerechnet, Gruppen nicht.

OFF-Panel: meine schmalere Formularspalte hatte den Vergleichs-Button abgeschnitten
und die OFF-Tabelle aus der Karte laufen lassen. Tabelle jetzt in .table-wrap
(scrollt/stapelt), Button-Text darf umbrechen, Formularspalte etwas breiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 09:32:45 +02:00
parent f78ae924de
commit 48d47f1d39
3 changed files with 73 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ export default function OffVergleich({
{abweichend.length === 0 && !bildAbweichend ? ( {abweichend.length === 0 && !bildAbweichend ? (
<p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p> <p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p>
) : ( ) : (
<div className="table-wrap">
<table className="table off-tabelle"> <table className="table off-tabelle">
<thead> <thead>
<tr> <tr>
@@ -93,6 +94,7 @@ export default function OffVergleich({
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </section>
); );

View File

@@ -6,7 +6,7 @@ import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath"; import { categoryInfoMap } from "../categoryPath";
import { locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { fmt } from "../units"; import { fmt } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
@@ -163,6 +163,55 @@ export default function MinStock() {
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
// Einen bestehenden Mindestbestand auf einen anderen Ort verschieben: „(Gesamt)"
// (ziel = "") oder ein Lagerort. Der Wert bleibt, der bisherige Geltungsbereich
// wird geleert so entsteht keine Dublette. Produkt-Werte werden zwischen
// Anzeige-/Artikel-/Basiseinheit umgerechnet (wie in saveSoll), Gruppen nicht.
async function moveRow(row, ziel) {
const aktuell = row.scope === "loc" ? row.locId : "";
if (String(ziel) === String(aktuell)) return;
if (row.soll == null) return; // nichts zu verschieben
const ent = row.entity;
// Zielort schon mit einem Bedarf belegt? Dann würde er überschrieben.
if (ziel && (ent.location_min_stocks || []).some((e) => String(e.location_id) === String(ziel))) {
const ok = await confirm({
title: "Lagerort schon belegt",
message: `Für „${row.name}" gibt es an diesem Lagerort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
confirmLabel: "Überschreiben", danger: true,
});
if (!ok) return;
}
setError(null);
try {
if (row.kind === "product") {
const p = ent;
const artikel = (row.soll * dispFactor(p)) / articleUnit(p);
let liste = p.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus
if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein
// Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt).
if (row.scope === "global" || !ziel) {
await api.updateProduct(p.id, {
min_stock: ziel ? null : Math.round(row.soll * dispFactor(p)),
min_stock_in_packages: false,
min_stock_unit_id: p.display_unit_id ?? null,
});
}
if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste);
} else {
const g = ent; // Gruppe: Gesamt und Ort teilen dieselbe Einheit keine Umrechnung
let liste = g.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null);
if (ziel) liste = mergeLoc(liste, ziel, row.soll);
if (row.scope === "global" || !ziel) {
await api.updateGroup(g.id, { min_stock: ziel ? null : row.soll });
}
if (row.scope === "loc" || ziel) await api.setGroupLocationMinStock(g.id, liste);
}
await load();
} catch (err) { setError(err.message); }
}
// Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst). // Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst).
const draftTarget = draft.kind === "product" const draftTarget = draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId)) ? products.find((p) => String(p.id) === String(draft.targetId))
@@ -224,8 +273,22 @@ export default function MinStock() {
render: (r) => (r.catId != null render: (r) => (r.catId != null
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" /> ? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" />
: <span className="muted"></span>) }, : <span className="muted"></span>) },
{ key: "ort", header: "Ort", width: 220, filterText: (r) => r.ort, sortValue: (r) => r.ort, { key: "ort", header: "Ort", width: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => (r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort) }, render: (r) => {
// Editierbar, wenn es einen Wert zu verschieben gibt (leere Produktzeilen
// ohne Bedarf bleiben Text dort legt man über „+ Mindestbestand" an).
if (!isAdmin || r.soll == null) {
return r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort;
}
return (
<select value={r.scope === "loc" ? r.locId : ""} style={{ marginTop: 0, minWidth: 150 }}
title="Lagerort ändern der Mindestbestand wird verschoben"
onChange={(e) => moveRow(r, e.target.value)}>
<option value="">(Gesamt)</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
);
} },
{ key: "soll", header: "Mindestbestand", width: 190, { key: "soll", header: "Mindestbestand", width: 190,
sortValue: (r) => r.soll ?? -1, sortValue: (r) => r.soll ?? -1,
render: (r) => (isAdmin ? ( render: (r) => (isAdmin ? (

View File

@@ -193,7 +193,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
halbe Seite -, rechts die Chargen-/Bestandstabelle, die den restlichen Platz halbe Seite -, rechts die Chargen-/Bestandstabelle, die den restlichen Platz
bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander
passen. Spiegelbild von .wide-aside (dort steht die Tabelle links). */ passen. Spiegelbild von .wide-aside (dort steht die Tabelle links). */
.grid-2.form-plus-table { grid-template-columns: clamp(440px, 34%, 640px) minmax(0, 1fr); align-items: start; } .grid-2.form-plus-table { grid-template-columns: clamp(480px, 38%, 720px) minmax(0, 1fr); align-items: start; }
@media (max-width: 1000px) { @media (max-width: 1000px) {
.grid-2.form-plus-table { grid-template-columns: minmax(0, 1fr); } .grid-2.form-plus-table { grid-template-columns: minmax(0, 1fr); }
} }
@@ -263,8 +263,10 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
/* Knoepfe neben dem Barcode-Feld: nebeneinander, mit Umbruch auf schmalen /* Knoepfe neben dem Barcode-Feld: nebeneinander, mit Umbruch auf schmalen
Fenstern. Das Eingabefeld daneben fuellt die restliche Breite. */ Fenstern. Das Eingabefeld daneben fuellt die restliche Breite. */
.knopf-spalte { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 0 auto; } .knopf-spalte { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 1 auto; min-width: 0; }
.knopf-spalte .btn { justify-content: center; white-space: nowrap; } /* Kein nowrap: in einer schmalen Formularspalte darf ein langer Button-Text
("Mit Open Food Facts vergleichen") umbrechen statt abgeschnitten zu werden. */
.knopf-spalte .btn { justify-content: center; white-space: normal; min-width: 0; }
/* Gegenueberstellung mit Open Food Facts */ /* Gegenueberstellung mit Open Food Facts */
.off-vergleich { .off-vergleich {