Datumsformat konfigurierbar (Einstellungen -> Darstellung)

MHD wurde in den Ansichten noch roh als YYYY-MM-DD ausgegeben. Jetzt:
- Neue Einstellung date_format (23.12.2026 | 23.12.26 | 23. Dezember 2026 |
  2026-12-23 | 12/23/2026) mit Live-Beispiel auf der Einstellungsseite.
- SettingsProvider stellt Format und formatDate() app-weit bereit und laedt die
  Einstellungen nach dem Login.
- Verwendet in Uebersicht (bald ablaufend), Produktdetail (Chargen) und
  Auslagern (Chargenauswahl). Datumsfelder zur Eingabe bleiben ISO, wie es das
  HTML-Datumsfeld verlangt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 15:04:01 +02:00
parent 4bb9a10366
commit 93e487a1b0
7 changed files with 112 additions and 7 deletions

View File

@@ -3,13 +3,16 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import { AuthProvider } from "./auth"; import { AuthProvider } from "./auth";
import { SettingsProvider } from "./settings";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<SettingsProvider>
<App /> <App />
</SettingsProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { useSettings } from "../settings";
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units"; import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
export default function CheckOut() { export default function CheckOut() {
const { formatDate } = useSettings();
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
@@ -159,7 +161,7 @@ export default function CheckOut() {
<option value="">Automatisch zuerst ablaufende zuerst (FEFO)</option> <option value="">Automatisch zuerst ablaufende zuerst (FEFO)</option>
{lots.map((l) => ( {lots.map((l) => (
<option key={l.id} value={l.id}> <option key={l.id} value={l.id}>
{l.best_before ? `MHD ${l.best_before}` : "ohne MHD"} · {lotAmount(l.quantity)} {l.best_before ? `MHD ${formatDate(l.best_before)}` : "ohne MHD"} · {lotAmount(l.quantity)}
{isExpired(l.best_before) ? " · abgelaufen" : ""} {isExpired(l.best_before) ? " · abgelaufen" : ""}
</option> </option>
))} ))}
@@ -170,7 +172,7 @@ export default function CheckOut() {
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}> <div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
<Icon name="alert" size={16} /> <Icon name="alert" size={16} />
Gewählte Charge: {lotAmount(selectedLot.quantity)} Gewählte Charge: {lotAmount(selectedLot.quantity)}
{selectedLot.best_before ? ` · MHD ${selectedLot.best_before}` : " · ohne MHD"} {selectedLot.best_before ? ` · MHD ${formatDate(selectedLot.best_before)}` : " · ohne MHD"}
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""} {isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
</div> </div>
)} )}

View File

@@ -2,9 +2,11 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { useSettings } from "../settings";
import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units"; import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
export default function Dashboard() { export default function Dashboard() {
const { formatDate } = useSettings();
const [expiring, setExpiring] = useState([]); const [expiring, setExpiring] = useState([]);
const [shopping, setShopping] = useState([]); const [shopping, setShopping] = useState([]);
const [groupShopping, setGroupShopping] = useState([]); const [groupShopping, setGroupShopping] = useState([]);
@@ -89,7 +91,7 @@ export default function Dashboard() {
<div className="muted small">{articleSecondary(it)}</div> <div className="muted small">{articleSecondary(it)}</div>
)} )}
</td> </td>
<td>{it.best_before}</td> <td>{formatDate(it.best_before)}</td>
<td> <td>
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}> <span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
{relativeExpiry(it.days_left)} {relativeExpiry(it.days_left)}

View File

@@ -5,6 +5,7 @@ import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup } from "../offUtils"; import { guessGroup } from "../offUtils";
import { useSettings } from "../settings";
import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units"; import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units";
const EMPTY = { const EMPTY = {
@@ -377,6 +378,7 @@ export default function ProductForm() {
/** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */
function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
const { formatDate } = useSettings();
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [draft, setDraft] = useState({ quantity: "", best_before: "" }); const [draft, setDraft] = useState({ quantity: "", best_before: "" });
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package". // In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
@@ -501,7 +503,7 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
style={{ marginTop: 0, minWidth: 150 }} /> style={{ marginTop: 0, minWidth: 150 }} />
) : ( ) : (
<> <>
{l.best_before || ""} {l.best_before ? formatDate(l.best_before) : ""}
{cls && ( {cls && (
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`} <span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
style={{ marginLeft: 6 }}> style={{ marginLeft: 6 }}>

View File

@@ -1,11 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { DATE_FORMAT_KEY, useSettings } from "../settings";
import { DATE_FORMATS, formatDate } from "../units";
const EXPIRY_KEY = "expiry_warning_days"; const EXPIRY_KEY = "expiry_warning_days";
export default function Settings() { export default function Settings() {
const { reload } = useSettings();
const [days, setDays] = useState(""); const [days, setDays] = useState("");
const [dateFormat, setDateFormat] = useState("de");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -13,8 +17,8 @@ export default function Settings() {
async function load() { async function load() {
try { try {
const settings = await api.listSettings(); const settings = await api.listSettings();
const row = settings.find((s) => s.key === EXPIRY_KEY); setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
setDays(row ? row.value : "7"); setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@@ -27,6 +31,8 @@ export default function Settings() {
setError(null); setInfo(null); setBusy(true); setError(null); setInfo(null); setBusy(true);
try { try {
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10))); await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
await reload();
setInfo("Gespeichert."); setInfo("Gespeichert.");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -35,6 +41,9 @@ export default function Settings() {
} }
} }
// Beispiel mit einem festen Datum, damit man die Wirkung sofort sieht.
const preview = formatDate("2026-12-23", dateFormat);
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -57,6 +66,20 @@ export default function Settings() {
Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder
bereits überschritten ist), erscheinen auf der Übersicht unter Bald ablaufend". bereits überschritten ist), erscheinen auf der Übersicht unter Bald ablaufend".
</p> </p>
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
<Icon name="settings" /><h2>Darstellung</h2>
</div>
<label>
Datumsformat
<select value={dateFormat} onChange={(e) => setDateFormat(e.target.value)}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</label>
<p className="muted small mt-0">Beispiel: {preview}</p>
<button className="btn primary" disabled={busy}>{busy ? "Speichern" : "Speichern"}</button> <button className="btn primary" disabled={busy}>{busy ? "Speichern" : "Speichern"}</button>
</form> </form>
</div> </div>

45
web/src/settings.jsx Normal file
View File

@@ -0,0 +1,45 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { api } from "./api";
import { useAuth } from "./auth";
import { formatDate } from "./units";
export const DATE_FORMAT_KEY = "date_format";
const DEFAULT_FORMAT = "de";
const SettingsContext = createContext({
dateFormat: DEFAULT_FORMAT,
formatDate: (iso) => formatDate(iso, DEFAULT_FORMAT),
reload: () => {},
});
/** Lädt die anzeigerelevanten Einstellungen einmal nach dem Login. */
export function SettingsProvider({ children }) {
const { user } = useAuth();
const [dateFormat, setDateFormat] = useState(DEFAULT_FORMAT);
const reload = useCallback(async () => {
try {
const rows = await api.listSettings();
const row = rows.find((r) => r.key === DATE_FORMAT_KEY);
setDateFormat(row?.value || DEFAULT_FORMAT);
} catch {
/* Einstellungen sind optional Standard bleibt bestehen. */
}
}, []);
useEffect(() => {
if (user) reload();
}, [user, reload]);
const value = {
dateFormat,
formatDate: (iso) => formatDate(iso, dateFormat),
reload,
};
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}
export function useSettings() {
return useContext(SettingsContext);
}

View File

@@ -53,6 +53,34 @@ export function isExpired(dateStr) {
return d != null && d < 0; 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 lesbar als Zeitspanne: "in 3 Tagen", "in 1 Woche", "vor 2 Wochen". // 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. // Deutsch nach "in"/"vor" steht im Dativ: 1 Tag / 3 Tagen, 1 Woche / 2 Wochen.
export function relativeExpiry(daysLeft) { export function relativeExpiry(daysLeft) {