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>
269 lines
9.6 KiB
JavaScript
269 lines
9.6 KiB
JavaScript
// 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)}`;
|
||
}
|