// 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 . */ 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)}`; }