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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user