Setzt den Backend-Umbau in beiden Oberflaechen um. Die Genauigkeit gilt jeweils fuer den ganzen Einlager-Vorgang und nicht je Charge: Auf einer Packung steht entweder ein Tagesdatum oder nur Monat/Jahr, gemischt kommt das nicht vor. Vorbelegt wird sie aus dem Produkt, laesst sich aber im Vorgang umstellen. Web-UI: Umschalter im Kopf der Chargenliste; das Eingabefeld wechselt zwischen type="date" und type="month". Im Produktformular gibt es das neue Feld "MHD-Angabe" neben der Gebinde-Bezeichnung. Anzeige laeuft ueber den Settings-Context, damit das eingestellte Datumsformat erhalten bleibt und Monatsangaben ueberall als "09/2026" erscheinen (Auslagern, Uebersicht, Chargentabelle). Die Abgelaufen-Warnung prueft bei Monatsangaben gegen den Monatsletzten - sonst haette eine Packung schon am Monatsersten als abgelaufen gegolten. iOS: Auswahl "Tagesdatum / nur Monat/Jahr" im Einlagern-Formular und beim Anlegen eines Artikels. SwiftUI hat keinen DatePicker ohne Tag, deshalb ein eigener MonthYearPicker aus zwei Auswahlfeldern; die Jahresliste reicht zwei Jahre zurueck (bereits abgelaufene Ware) und fuenfzehn nach vorn (Konserven). Die Chargenauswahl beim Auslagern zeigt Monatsangaben ebenfalls ohne Tag. Getestet: Web-Build (vite) und iOS-Geraetebuild laufen fehlerfrei durch, die App ist auf dem iPhone installiert. Das Verhalten in der Oberflaeche ist noch nicht von Hand durchgeklickt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
207 lines
7.7 KiB
JavaScript
207 lines
7.7 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
import { api } from "../api";
|
||
import Icon from "../components/Icon";
|
||
import { useSettings } from "../settings";
|
||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||
|
||
export default function CheckOut() {
|
||
const { formatBestBefore } = useSettings();
|
||
const [barcode, setBarcode] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const [results, setResults] = useState([]);
|
||
const [product, setProduct] = useState(null);
|
||
const [units, setUnits] = useState([]);
|
||
const [lots, setLots] = useState([]);
|
||
|
||
const [quantity, setQuantity] = useState("");
|
||
const [unit, setUnit] = useState("");
|
||
const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO)
|
||
|
||
const [error, setError] = useState(null);
|
||
const [info, setInfo] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api.listUnits().then(setUnits).catch(() => {});
|
||
}, []);
|
||
|
||
async function selectProduct(p) {
|
||
setProduct(p);
|
||
setResults([]);
|
||
setLotId("");
|
||
setQuantity("");
|
||
const opts = buildUnitOptions(p, units);
|
||
// Gibt es ein Gebinde (Glas, Packung, …), ist das die naheliegende Eingabe.
|
||
setUnit(p.package_size ? "package" : (p.unit_name || (opts[0] && opts[0].value) || ""));
|
||
try {
|
||
setLots(await api.listLots(p.id));
|
||
} catch { setLots([]); }
|
||
}
|
||
|
||
async function doLookup() {
|
||
setError(null); setInfo(null);
|
||
if (!barcode) return;
|
||
try {
|
||
const res = await api.lookup(barcode.trim());
|
||
if (res.found && res.existing_product) {
|
||
await selectProduct(res.existing_product);
|
||
} else {
|
||
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
|
||
}
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function doSearch(e) {
|
||
e.preventDefault();
|
||
try {
|
||
setResults(await api.listProducts(search));
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
|
||
async function submit(e) {
|
||
e.preventDefault();
|
||
setError(null); setInfo(null); setBusy(true);
|
||
try {
|
||
const res = await api.checkOut({
|
||
product_id: product.id,
|
||
quantity: Number(quantity),
|
||
unit,
|
||
lot_id: lotId === "" ? null : Number(lotId),
|
||
});
|
||
setInfo(
|
||
`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ` +
|
||
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
|
||
);
|
||
setQuantity("");
|
||
const fresh = await api.getProduct(product.id);
|
||
setProduct(fresh);
|
||
setLots(await api.listLots(fresh.id));
|
||
setLotId("");
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
const unitOpts = product ? buildUnitOptions(product, units) : [];
|
||
const selectedLot = lots.find((l) => String(l.id) === String(lotId)) || null;
|
||
const baseShort = product ? unitShort(product.base_unit) : "";
|
||
const pkgSize = product?.package_size || 0;
|
||
const pkgLabel = product?.package_label || "Packung";
|
||
|
||
// Chargenmenge in der Artikeleinheit (Gebinde), sonst in der Produkteinheit.
|
||
function lotAmount(q) {
|
||
if (pkgSize > 0) return `${fmt(q / pkgSize)} ${pkgLabel}`;
|
||
return `${fmt(q / (product?.unit_factor || 1))} ${product?.unit_name || baseShort}`;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head"><h1>Auslagern</h1></div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
{info && <div className="alert ok"><Icon name="check" size={16} />{info}</div>}
|
||
|
||
{!product && (
|
||
<div className="grid-2">
|
||
<div className="card">
|
||
<div className="card-head"><Icon name="search" /><h2>Per Barcode</h2></div>
|
||
<div className="field-inline">
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
<input placeholder="Barcode eingeben oder scannen" value={barcode}
|
||
onChange={(e) => setBarcode(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && doLookup()} autoFocus />
|
||
</label>
|
||
<button className="btn primary" onClick={doLookup}>Suchen</button>
|
||
</div>
|
||
</div>
|
||
<div className="card">
|
||
<div className="card-head"><Icon name="package" /><h2>Aus Produktliste</h2></div>
|
||
<form className="field-inline" onSubmit={doSearch}>
|
||
<label className="grow" style={{ margin: 0 }}>
|
||
<input placeholder="Name suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
</label>
|
||
<button className="btn">Suchen</button>
|
||
</form>
|
||
<ul className="picklist">
|
||
{results.map((p) => (
|
||
<li key={p.id}>
|
||
<button className="link-btn" onClick={() => selectProduct(p)}>
|
||
{p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{product && (
|
||
<form className="card form-narrow" onSubmit={submit}>
|
||
<div className="selected-product">
|
||
{product.image_url
|
||
? <img className="thumb" src={product.image_url} alt="" />
|
||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||
<div className="info">
|
||
<div className="title">{product.name}</div>
|
||
<div className="muted small">
|
||
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||
</div>
|
||
|
||
<label>
|
||
Charge
|
||
<select value={lotId} onChange={(e) => setLotId(e.target.value)}>
|
||
<option value="">Automatisch – zuerst ablaufende zuerst (FEFO)</option>
|
||
{lots.map((l) => (
|
||
<option key={l.id} value={l.id}>
|
||
{l.best_before ? `MHD ${formatBestBefore(l.best_before, l.best_before_precision)}` : "ohne MHD"} · {lotAmount(l.quantity)}
|
||
{isExpired(l.best_before) ? " · abgelaufen" : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
{selectedLot && (
|
||
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
|
||
<Icon name="alert" size={16} />
|
||
Gewählte Charge: {lotAmount(selectedLot.quantity)}
|
||
{selectedLot.best_before ? ` · MHD ${formatBestBefore(selectedLot.best_before, selectedLot.best_before_precision)}` : " · ohne MHD"}
|
||
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
|
||
</div>
|
||
)}
|
||
|
||
<div className="row">
|
||
<label className="grow">
|
||
Menge
|
||
<input type="number" step="any" min="0" value={quantity} required
|
||
onChange={(e) => setQuantity(e.target.value)} autoFocus />
|
||
</label>
|
||
<label className="grow">
|
||
Einheit
|
||
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
|
||
{unitOpts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
{!selectedLot && (
|
||
<p className="muted small mt-0">
|
||
Ohne Auswahl wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||
</p>
|
||
)}
|
||
<button className="btn primary" disabled={busy}>
|
||
<Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}
|
||
</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|