Files
Vorrania/web/src/units.js
Scarriffle 788ef2caf2 Web: Zweiteinheit erfassen und anzeigen
Im Artikel-Formular eine Zeile "3 [Stk] ≙ 250 [Gramm]" mit ausgerechneter
Kontrolle darunter ("1 Stk ≈ 83,33 g"). Links steht die Basiseinheit des
Artikels fest, rechts stehen nur die anderen Arten zur Wahl - eine Bruecke auf
die eigene Art haette nichts umzurechnen. Nur vollstaendig ausgefuellt wird sie
gespeichert; Gegenstaende ohne Charge tragen keine.

buildUnitOptions laesst bei hinterlegter Bruecke auch die Einheiten der Gegenart
zu - damit erben Ein- und Auslagern die Faehigkeit ohne eigene Logik. Beim
Auslagern steht der Bestand jetzt in beiden Lesarten ("6 Stueck · 500 g").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:40:33 +02:00

269 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Anzeige-Helfer für Einheiten (müssen zu backend/app/models.py::BaseUnit passen).
// ---- Gebinde: Einzahl und Mehrzahl ----
// Der Artikel speichert nur die Einzahl ("Glas"); die Mehrzahl steht in der
// Verwaltung (Tabelle package_types) und wird nach dem Login einmal geladen.
//
// Bewusst eine Modulvariable und kein React-Kontext: Die Formatierer hier sind
// gewöhnliche Funktionen, die auch außerhalb von Komponenten aufgerufen werden.
// Sie alle in Hooks zu verwandeln, hieße jede Aufrufstelle umzubauen, um eine
// Liste durchzureichen, die sich praktisch nie ändert.
let GEBINDE_MEHRZAHL = {};
export function setGebindeFormen(liste) {
GEBINDE_MEHRZAHL = Object.fromEntries(
(liste || []).map((t) => [t.singular, t.plural || t.singular]),
);
}
/**
* Gebinde-Bezeichnung passend zur Menge: "1 Glas", "3 Gläser".
*
* Unbekannte Bezeichnungen bleiben unverändert lieber eine fehlende Mehrzahl
* als ein erfundenes "Glass". Nicht ganze Mengen zählen als Mehrzahl
* ("0,5 Gläser"), so wie im Deutschen alles außer der Eins.
*/
export function gebinde(menge, einzahl) {
const wort = einzahl || "Packung";
if (Math.abs(Number(menge)) === 1) return wort;
return GEBINDE_MEHRZAHL[wort] || wort;
}
export const BASE_UNITS = [
{ value: "piece", label: "Stück" },
{ value: "gram", label: "Gramm (g)" },
{ value: "milliliter", label: "Milliliter (ml)" },
];
export const BASE_UNIT_SHORT = {
piece: "Stk",
gram: "g",
milliliter: "ml",
};
export function unitShort(baseUnit) {
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
}
// Basiseinheit-Kürzel zur Einheiten-ART (weight/volume/count), wie sie Gruppen
// über `kind` melden. Mindestbestände stehen immer in dieser Einheit.
export const KIND_SHORT = { weight: "g", volume: "ml", count: "Stück" };
export function kindShort(kind) {
return KIND_SHORT[kind] || "";
}
// Einheiten-ART je Basiseinheit Spiegelbild von conversion.py::KIND_OF_BASE.
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
// ---- Zweiteinheit: die Brücke zwischen den Einheiten-Arten ----
// Ein Artikel darf zusätzlich zu seiner Basiseinheit eine Äquivalenz tragen:
// „3 Stück ≙ 250 g". Der Server liefert den ausgerechneten Faktor als
// `secondary_factor` mit; das Paar dahinter bleibt zur Anzeige erhalten.
/** Zweit-Basiseinheiten je EINER Basiseinheit. 0 = keine Brücke hinterlegt. */
export function zweitFaktor(p) {
if (!p?.secondary_base) return 0;
const f = Number(p.secondary_factor);
if (f > 0) return f;
// Rückfall, falls das abgeleitete Feld fehlt (ältere Antwort).
const n = Number(p.secondary_count);
const m = Number(p.secondary_amount);
return n > 0 && m > 0 ? m / n : 0;
}
/** Eine Menge (Basiseinheiten) in der Zweiteinheit lesen: „500 g". */
export function zweitText(mengeBase, p) {
const f = zweitFaktor(p);
return f ? `${fmt(mengeBase * f)} ${unitShort(p.secondary_base)}` : "";
}
/** Bestand mit beiden Lesarten: „6 Stück · 500 g". */
export function stockLabelMitZweit(p) {
const zweit = zweitText(p.stock || 0, p);
return zweit ? `${stockLabel(p)} · ${zweit}` : stockLabel(p);
}
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
export function unitOptions(product) {
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
if (product.package_size) {
opts.push({ value: "package", label: `Packung (${product.package_size} ${unitShort(product.base_unit)})` });
}
return opts;
}
// Das Backend akzeptiert 'g', 'ml', 'piece'/'st' etc. als Basiseinheit-Eingabe.
export function baseUnitToInput(baseUnit) {
if (baseUnit === "gram") return "g";
if (baseUnit === "milliliter") return "ml";
return "piece";
}
export function fmt(n) {
if (n == null) return "";
return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 });
}
// Tage bis zum MHD (negativ = überfällig, null = kein MHD).
export function daysUntil(dateStr) {
if (!dateStr) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(dateStr);
d.setHours(0, 0, 0, 0);
return Math.round((d - today) / 86400000);
}
// Ist ein MHD (YYYY-MM-DD) bereits abgelaufen (vor heute)?
export function isExpired(dateStr) {
const d = daysUntil(dateStr);
return d != null && d < 0;
}
// Auswählbare Datumsformate (Einstellungen -> Darstellung).
export const DATE_FORMATS = [
{ value: "de", label: "23.12.2026" },
{ value: "de-short", label: "23.12.26" },
{ value: "long", label: "23. Dezember 2026" },
{ value: "iso", label: "2026-12-23" },
{ value: "us", label: "12/23/2026" },
];
// Formatiert ein ISO-Datum (YYYY-MM-DD) nach dem gewählten Format.
export function formatDate(iso, format = "de") {
if (!iso) return "";
const date = new Date(`${iso}T00:00:00`);
if (Number.isNaN(date.getTime())) return iso;
switch (format) {
case "iso":
return iso;
case "de-short":
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
case "long":
return date.toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" });
case "us":
return date.toLocaleDateString("en-US");
default:
return date.toLocaleDateString("de-DE");
}
}
// ---- MHD-Genauigkeit ----
// Intern steht immer ein volles Datum (bei Monatsangaben der Monatsletzte).
// Nur die Anzeige und das Eingabefeld richten sich nach der Genauigkeit.
/** MHD anzeigen: Monatsangaben ohne Tag, also "09/2026". */
export function formatBestBefore(iso, precision, format = "de") {
if (!iso) return "";
if (precision !== "month") return formatDate(iso, format);
const date = new Date(`${iso}T00:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return `${String(date.getMonth() + 1).padStart(2, "0")}/${date.getFullYear()}`;
}
/** "2026-09-30" -> "2026-09" für <input type="month">. */
export function toMonthInput(iso) {
return iso ? iso.slice(0, 7) : "";
}
/** "2026-09" -> "2026-09-01"; das Backend legt daraus den Monatsletzten. */
export function fromMonthInput(value) {
return value ? `${value}-01` : "";
}
/** "2026-09" -> "2026-09-30". Fuer Ablauf-Warnungen: ein Monats-MHD laeuft
* erst am Monatsende ab, nicht am Monatsanfang. */
export function monthInputToLastDay(value) {
if (!value) return "";
const [year, month] = value.split("-").map(Number);
if (!year || !month) return "";
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
}
// MHD lesbar als Zeitspanne: "in 3 Tagen", "in 1 Woche", "vor 2 Wochen".
// Deutsch nach "in"/"vor" steht im Dativ: 1 Tag / 3 Tagen, 1 Woche / 2 Wochen.
export function relativeExpiry(daysLeft) {
if (daysLeft == null) return "";
if (daysLeft === 0) return "heute";
if (daysLeft === 1) return "morgen";
if (daysLeft === -1) return "gestern";
const abs = Math.abs(daysLeft);
let value;
let unit;
if (abs < 7) {
value = abs;
unit = value === 1 ? "Tag" : "Tagen";
} else if (abs < 60) {
value = Math.round(abs / 7);
unit = value === 1 ? "Woche" : "Wochen";
} else {
value = Math.round(abs / 30);
unit = value === 1 ? "Monat" : "Monaten";
}
return daysLeft > 0 ? `in ${value} ${unit}` : `vor ${value} ${unit}`;
}
// CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist.
export function expiryRowClass(dateStr, warnDays = 7) {
const d = daysUntil(dateStr);
if (d == null) return "";
if (d < 0) return "row-danger";
if (d <= warnDays) return "row-warn";
return "";
}
// Menge lesbar darstellen: bei Packungsprodukten in Packungen (+ Basiseinheit in Klammern).
export function amountText(qty, packageSize, baseUnit) {
if (packageSize && packageSize > 0) {
return `${fmt(qty / packageSize)} Pkg (${fmt(qty)} ${unitShort(baseUnit)})`;
}
return `${fmt(qty)} ${unitShort(baseUnit)}`;
}
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
export function buildUnitOptions(product, units) {
// Ohne Zweiteinheit nur die eigene Art. Mit Brücke kommen die Einheiten der
// Gegenart dazu das Backend rechnet sie über die am Artikel hinterlegte
// Äquivalenz um (services/conversion.py::to_base).
const erlaubt = new Set([product.kind]);
if (zweitFaktor(product)) erlaubt.add(KIND_OF_BASE[product.secondary_base]);
const opts = (units || [])
.filter((u) => erlaubt.has(u.kind))
.map((u) => ({ value: u.name, label: u.name }));
if (product.package_size) {
// Bezeichnung des Gebindes je Artikel (Glas, Tüte, …); Wert bleibt "package".
opts.push({
value: "package",
label: `${product.package_label || "Packung"} (${fmt(product.package_size)} ${unitShort(product.base_unit)})`,
});
}
return opts;
}
// Leitangabe einer Menge in Artikeleinheiten (Gebinde), sonst in der Produkteinheit.
// Erwartet ein Objekt mit quantity, package_size, package_label, unit_name, unit_factor, base_unit.
export function articlePrimary(item) {
if (item.package_size > 0) {
const anzahl = item.quantity / item.package_size;
return `${fmt(anzahl)} ${gebinde(anzahl, item.package_label)}`;
}
return `${fmt(item.quantity / (item.unit_factor || 1))} ${item.unit_name || unitShort(item.base_unit)}`;
}
// Untermenge in der Basiseinheit nur wenn sie sich von der Leitangabe unterscheidet.
export function articleSecondary(item) {
if (item.package_size > 0 || (item.unit_factor || 1) !== 1) {
return `${fmt(item.quantity)} ${unitShort(item.base_unit)}`;
}
return "";
}
// Bestand eines Produkts in seiner Anzeigeeinheit, z.B. "1,5 Kilogramm".
export function stockLabel(product) {
const factor = product.unit_factor || 1;
return `${fmt((product.stock || 0) / factor)} ${product.unit_name || unitShort(product.base_unit)}`;
}