iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten

iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
  Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
  oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
  projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
  diesem Rechner ist kein Xcode vorhanden.

Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
  genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
  Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
  fragt bei den zerstoerenden Modi nach.

Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
  Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
  "vor 2 Wochen", dazu heute/morgen/gestern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 14:59:04 +02:00
co-authored by Claude Opus 4.8
parent de6c8632a1
commit 4bb9a10366
23 changed files with 1770 additions and 41 deletions
+5 -2
View File
@@ -139,10 +139,13 @@ export const api = {
// Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),
exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"),
importStock: (file) => {
importStock: (file, mode = "add") => {
const fd = new FormData();
fd.append("file", file);
return request("/import/stock", { method: "POST", formData: fd });
return request(`/import/stock?mode=${encodeURIComponent(mode)}`, {
method: "POST",
formData: fd,
});
},
listUnits: () => request("/units"),
+12 -7
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
import { amountText, fmt, unitShort } from "../units";
import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
export default function Dashboard() {
const [expiring, setExpiring] = useState([]);
@@ -77,18 +77,23 @@ export default function Dashboard() {
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
<td>{it.product_name}</td>
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>
<td className="num">
{it.days_left < 0
? <span className="badge danger">{-it.days_left}d überf.</span>
: it.days_left}
{articlePrimary(it)}
{articleSecondary(it) && (
<div className="muted small">{articleSecondary(it)}</div>
)}
</td>
<td>{it.best_before}</td>
<td>
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
{relativeExpiry(it.days_left)}
</span>
</td>
</tr>
))}
+4 -20
View File
@@ -1,26 +1,10 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitShort } from "../units";
import { articlePrimary, articleSecondary } from "../units";
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
// Leitangabe in Artikeleinheiten (Gebinde), sonst in der Produkteinheit.
function primaryAmount(m) {
if (m.package_size > 0) {
return `${fmt(m.quantity / m.package_size)} ${m.package_label || "Packung"}`;
}
return `${fmt(m.quantity / (m.unit_factor || 1))} ${m.unit_name || unitShort(m.base_unit)}`;
}
// Untermenge in der Basiseinheit – nur wenn sie sich von der Leitangabe unterscheidet.
function secondaryAmount(m) {
if (m.package_size > 0 || (m.unit_factor || 1) !== 1) {
return `${fmt(m.quantity)} ${unitShort(m.base_unit)}`;
}
return "";
}
export default function History() {
const [movements, setMovements] = useState([]);
const [error, setError] = useState(null);
@@ -63,9 +47,9 @@ export default function History() {
</td>
<td>{m.product_name}</td>
<td className="num">
{primaryAmount(m)}
{secondaryAmount(m) && (
<div className="muted small">{secondaryAmount(m)}</div>
{articlePrimary(m)}
{articleSecondary(m) && (
<div className="muted small">{articleSecondary(m)}</div>
)}
</td>
<td className="muted">{m.username || "–"}</td>
+7 -3
View File
@@ -5,7 +5,7 @@ import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units";
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
@@ -502,8 +502,12 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
) : (
<>
{l.best_before || "–"}
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
{cls && (
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
style={{ marginLeft: 6 }}>
{relativeExpiry(dLeft)}
</span>
)}
</>
)}
</td>
+31 -4
View File
@@ -5,10 +5,19 @@ import Icon from "../components/Icon";
export default function Transfer() {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [mode, setMode] = useState("add");
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const MODES = {
add: "Nur hinzufügen – es wird nichts gelöscht.",
replace_listed:
"Bestand der Produkte ersetzen, die in der Datei vorkommen. Ideal für eine Inventur: exportieren, korrigieren, zurückspielen.",
replace_all:
"Alle Bestände vorher leeren und komplett neu aufbauen. Für die vollständige Wiederherstellung aus einem Backup.",
};
async function download(kind) {
setError(null);
try {
@@ -22,11 +31,18 @@ export default function Transfer() {
async function doImport(e) {
e.preventDefault();
if (!file) return;
if (mode !== "add") {
const warning =
mode === "replace_all"
? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?"
: "Für alle Produkte in der Datei werden die vorhandenen Chargen gelöscht und ersetzt. Fortfahren?";
if (!confirm(warning)) return;
}
setError(null);
setResult(null);
setBusy(true);
try {
setResult(await api.importStock(file));
setResult(await api.importStock(file, mode));
setFile(null);
if (fileRef.current) fileRef.current.value = "";
} catch (err) {
@@ -68,9 +84,8 @@ export default function Transfer() {
<section className="card">
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
<p className="muted small mt-0">
CSV oder JSON auswählen. Der Import <strong>ergänzt nur</strong>: unbekannte Produkte,
Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht
oder überschrieben.
CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer
angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.
</p>
<form onSubmit={doImport}>
<label>
@@ -78,6 +93,17 @@ export default function Transfer() {
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label>
<label>
Modus
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="add">Nur hinzufügen</option>
<option value="replace_listed">Bestände der enthaltenen Produkte ersetzen</option>
<option value="replace_all">Alle Bestände ersetzen</option>
</select>
</label>
<p className={`muted small mt-0 ${mode === "replace_all" ? "hint-danger" : ""}`}>
{MODES[mode]}
</p>
<button className="btn primary" disabled={!file || busy}>
{busy ? "Importiere…" : "Importieren"}
</button>
@@ -89,6 +115,7 @@ export default function Transfer() {
<span>
Import abgeschlossen ({result.format?.toUpperCase()}):{" "}
{result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt
{result.products_cleared ? `, ${result.products_cleared} Produkt(e) vorher geleert` : ""}
{result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}.
</span>
</div>
+41
View File
@@ -53,6 +53,30 @@ export function isExpired(dateStr) {
return d != null && d < 0;
}
// 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);
@@ -85,6 +109,23 @@ export function buildUnitOptions(product, units) {
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) {
return `${fmt(item.quantity / item.package_size)} ${item.package_label || "Packung"}`;
}
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;