Files
Vorrania/web/src/units.js
Scarriffle fd9045226b Gebinde verwalten - mit Einzahl und Mehrzahl
Bisher war die Auswahl eine fest im Frontend verdrahtete Liste und es gab nur
eine Form: ueberall stand "3 Glas".

Neu unter Verwaltung > Gebinde: anlegen, umbenennen, loeschen, jeweils mit
Einzahl und Mehrzahl. Die eingebauten Gebinde lassen sich in der Schreibweise
aendern, aber nicht loeschen; ein Gebinde, das ein Artikel verwendet, ebenfalls
nicht.

Der Artikel speichert weiterhin nur die Einzahl als Text - so bleiben
vorhandene Artikel, Sicherungen und CSV-Dateien gueltig, und eine unbekannte
Bezeichnung faellt schlicht auf die Einzahl zurueck. Deshalb zieht ein
Umbenennen die Artikel mit; sonst zeigten sie auf eine Bezeichnung, die es
nicht mehr gibt.

Die Mehrzahl greift jetzt in Artikelliste, Artikelseite (Chargen und Gebinde-
Auswahl), Auslagern und in allen Ablauf- und Einkaufslisten samt Startseite.
Einheiten wie Gramm oder Liter bleiben unveraendert - die haben im Deutschen
keine Mehrzahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:24:42 +02:00

225 lines
7.8 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;
}
// 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) {
const opts = (units || [])
.filter((u) => u.kind === product.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)}`;
}