MHD nur mit Monat und Jahr in Web-UI und iOS-App
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>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,3 +29,5 @@ ios/ProjectGood.xcodeproj/
|
|||||||
|
|
||||||
# Lokale Python-Umgebung fuer Tests
|
# Lokale Python-Umgebung fuer Tests
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
/// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt.
|
||||||
|
var startOfMonth: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let parts = calendar.dateComponents([.year, .month], from: self)
|
||||||
|
return calendar.date(from: parts) ?? self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monat und Jahr auswaehlen. SwiftUI kennt keinen DatePicker ohne Tag,
|
||||||
|
/// deshalb zwei schmale Auswahlfelder nebeneinander.
|
||||||
|
struct MonthYearPicker: View {
|
||||||
|
@Binding var date: Date
|
||||||
|
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private var monthNames: [String] { calendar.monthSymbols }
|
||||||
|
|
||||||
|
private var years: [Int] {
|
||||||
|
let current = calendar.component(.year, from: Date())
|
||||||
|
// Rueckwaerts fuer bereits abgelaufene Ware, vorwaerts fuer Konserven.
|
||||||
|
return Array((current - 2)...(current + 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var month: Binding<Int> {
|
||||||
|
Binding(
|
||||||
|
get: { calendar.component(.month, from: date) },
|
||||||
|
set: { date = replacing(month: $0, year: calendar.component(.year, from: date)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var year: Binding<Int> {
|
||||||
|
Binding(
|
||||||
|
get: { calendar.component(.year, from: date) },
|
||||||
|
set: { date = replacing(month: calendar.component(.month, from: date), year: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replacing(month: Int, year: Int) -> Date {
|
||||||
|
calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? date
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("MHD")
|
||||||
|
Spacer()
|
||||||
|
Picker("Monat", selection: month) {
|
||||||
|
ForEach(1...12, id: \.self) { Text(monthNames[$0 - 1]).tag($0) }
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
Picker("Jahr", selection: year) {
|
||||||
|
ForEach(years, id: \.self) { Text(String($0)).tag($0) }
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
|
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
|
||||||
struct CheckInFormView: View {
|
struct CheckInFormView: View {
|
||||||
let product: Product
|
let product: Product
|
||||||
@@ -17,6 +74,9 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@State private var lines: [Line] = [Line()]
|
@State private var lines: [Line] = [Line()]
|
||||||
|
/// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein
|
||||||
|
/// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
|
||||||
|
@State private var precision: String = "day"
|
||||||
@State private var unit: String = ""
|
@State private var unit: String = ""
|
||||||
@State private var locationId: Int?
|
@State private var locationId: Int?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@@ -63,6 +123,13 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("MHD") {
|
||||||
|
Picker("Angabe", selection: $precision) {
|
||||||
|
Text("Tagesdatum").tag("day")
|
||||||
|
Text("nur Monat/Jahr").tag("month")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Chargen") {
|
Section("Chargen") {
|
||||||
ForEach($lines) { $line in
|
ForEach($lines) { $line in
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -84,7 +151,11 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
Toggle("MHD angeben", isOn: $line.hasDate)
|
Toggle("MHD angeben", isOn: $line.hasDate)
|
||||||
if line.hasDate {
|
if line.hasDate {
|
||||||
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
|
if precision == "month" {
|
||||||
|
MonthYearPicker(date: $line.bestBefore)
|
||||||
|
} else {
|
||||||
|
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -113,6 +184,8 @@ struct CheckInFormView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||||
unit = packageOptionName ?? product.unitName
|
unit = packageOptionName ?? product.unitName
|
||||||
|
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr steht.
|
||||||
|
precision = product.datePrecision == "month" ? "month" : "day"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +201,13 @@ struct CheckInFormView: View {
|
|||||||
let payloadLines: [CheckInLine] = lines.compactMap { line in
|
let payloadLines: [CheckInLine] = lines.compactMap { line in
|
||||||
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
|
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
|
||||||
quantity > 0 else { return nil }
|
quantity > 0 else { return nil }
|
||||||
|
// Bei Monatsangabe genuegt der Monatserste - der Server legt daraus
|
||||||
|
// den Monatsletzten und merkt sich die Genauigkeit.
|
||||||
|
let date = precision == "month" ? line.bestBefore.startOfMonth : line.bestBefore
|
||||||
return CheckInLine(
|
return CheckInLine(
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
bestBefore: line.hasDate ? formatter.string(from: line.bestBefore) : nil,
|
bestBefore: line.hasDate ? formatter.string(from: date) : nil,
|
||||||
|
bestBeforePrecision: precision,
|
||||||
locationId: locationId
|
locationId: locationId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,8 +190,16 @@ struct CheckOutFormView: View {
|
|||||||
|
|
||||||
private func label(for lot: Lot) -> String {
|
private func label(for lot: Lot) -> String {
|
||||||
let amount = format(lot.quantity / product.articleUnitFactor)
|
let amount = format(lot.quantity / product.articleUnitFactor)
|
||||||
let date = lot.bestBefore ?? "ohne MHD"
|
return "\(bestBeforeText(lot)) · \(amount) \(product.articleUnitLabel)"
|
||||||
return "\(date) · \(amount) \(product.articleUnitLabel)"
|
}
|
||||||
|
|
||||||
|
/// Monatsangaben ohne Tag zeigen: "09/2026" statt "2026-09-30".
|
||||||
|
private func bestBeforeText(_ lot: Lot) -> String {
|
||||||
|
guard let raw = lot.bestBefore else { return "ohne MHD" }
|
||||||
|
guard lot.bestBeforePrecision == "month" else { return raw }
|
||||||
|
let parts = raw.split(separator: "-")
|
||||||
|
guard parts.count >= 2 else { return raw }
|
||||||
|
return "\(parts[1])/\(parts[0])"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func format(_ value: Double) -> String {
|
private func format(_ value: Double) -> String {
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
let unitName: String
|
let unitName: String
|
||||||
let unitFactor: Double
|
let unitFactor: Double
|
||||||
let barcodes: [BarcodeEntry]
|
let barcodes: [BarcodeEntry]
|
||||||
|
/// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month").
|
||||||
|
let datePrecision: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, barcode, name, brand, stock, kind, barcodes
|
case id, barcode, name, brand, stock, kind, barcodes
|
||||||
|
case datePrecision = "date_precision"
|
||||||
case imageUrl = "image_url"
|
case imageUrl = "image_url"
|
||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
@@ -118,12 +121,14 @@ struct Lot: Codable, Identifiable, Hashable {
|
|||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let bestBefore: String?
|
let bestBefore: String?
|
||||||
|
let bestBeforePrecision: String?
|
||||||
let locationId: Int?
|
let locationId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, quantity
|
case id, quantity
|
||||||
case productId = "product_id"
|
case productId = "product_id"
|
||||||
case bestBefore = "best_before"
|
case bestBefore = "best_before"
|
||||||
|
case bestBeforePrecision = "best_before_precision"
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,11 +147,13 @@ struct StorageLocation: Codable, Identifiable, Hashable {
|
|||||||
struct CheckInLine: Codable {
|
struct CheckInLine: Codable {
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let bestBefore: String?
|
let bestBefore: String?
|
||||||
|
let bestBeforePrecision: String
|
||||||
let locationId: Int?
|
let locationId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity
|
case quantity
|
||||||
case bestBefore = "best_before"
|
case bestBefore = "best_before"
|
||||||
|
case bestBeforePrecision = "best_before_precision"
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +198,7 @@ struct NewProductRequest: Codable {
|
|||||||
let baseUnit: String
|
let baseUnit: String
|
||||||
let packageSize: Double?
|
let packageSize: Double?
|
||||||
let packageLabel: String?
|
let packageLabel: String?
|
||||||
|
let datePrecision: String
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -199,6 +207,7 @@ struct NewProductRequest: Codable {
|
|||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
case packageLabel = "package_label"
|
case packageLabel = "package_label"
|
||||||
|
case datePrecision = "date_precision"
|
||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ struct ProductFormView: View {
|
|||||||
@State private var baseUnit = "piece"
|
@State private var baseUnit = "piece"
|
||||||
@State private var packageSize = ""
|
@State private var packageSize = ""
|
||||||
@State private var packageLabel = ""
|
@State private var packageLabel = ""
|
||||||
|
@State private var datePrecision = "day"
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -87,6 +88,12 @@ struct ProductFormView: View {
|
|||||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section("MHD") {
|
||||||
|
Picker("Angabe", selection: $datePrecision) {
|
||||||
|
Text("Tagesdatum").tag("day")
|
||||||
|
Text("nur Monat/Jahr").tag("month")
|
||||||
|
}
|
||||||
|
}
|
||||||
if let error {
|
if let error {
|
||||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
}
|
}
|
||||||
@@ -124,6 +131,7 @@ struct ProductFormView: View {
|
|||||||
baseUnit: baseUnit,
|
baseUnit: baseUnit,
|
||||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
|
datePrecision: datePrecision,
|
||||||
groupId: groupId
|
groupId: groupId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
1878
web/package-lock.json
generated
Normal file
1878
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { api } from "../api";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { guessGroup, suggestionToProduct } from "../offUtils";
|
import { guessGroup, suggestionToProduct } from "../offUtils";
|
||||||
import { buildUnitOptions, fmt, isExpired } from "../units";
|
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
||||||
|
|
||||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||||
|
|
||||||
@@ -25,6 +25,9 @@ export default function CheckIn() {
|
|||||||
const [unit, setUnit] = useState("");
|
const [unit, setUnit] = useState("");
|
||||||
const [locationId, setLocationId] = useState("");
|
const [locationId, setLocationId] = useState("");
|
||||||
const [lines, setLines] = useState([emptyLine()]);
|
const [lines, setLines] = useState([emptyLine()]);
|
||||||
|
// Genauigkeit gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder
|
||||||
|
// ein Tagesdatum oder nur Monat/Jahr - nicht beides gemischt.
|
||||||
|
const [precision, setPrecision] = useState("day");
|
||||||
|
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
@@ -49,6 +52,8 @@ export default function CheckIn() {
|
|||||||
// Gibt es ein Gebinde (Glas, Packung, …), ist das die naheliegende Eingabe.
|
// Gibt es ein Gebinde (Glas, Packung, …), ist das die naheliegende Eingabe.
|
||||||
setUnit(p.package_size ? "package" : (p.unit_name || (opts[0] && opts[0].value) || ""));
|
setUnit(p.package_size ? "package" : (p.unit_name || (opts[0] && opts[0].value) || ""));
|
||||||
setLines([emptyLine()]);
|
setLines([emptyLine()]);
|
||||||
|
// Am Produkt hinterlegt, ob dort ueblicherweise nur Monat/Jahr aufgedruckt ist.
|
||||||
|
setPrecision(p.date_precision === "month" ? "month" : "day");
|
||||||
setLocationId("");
|
setLocationId("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +114,11 @@ export default function CheckIn() {
|
|||||||
|
|
||||||
const totalQty = lines.reduce((s, l) => s + (Number(l.quantity) || 0), 0);
|
const totalQty = lines.reduce((s, l) => s + (Number(l.quantity) || 0), 0);
|
||||||
|
|
||||||
|
// Ein Monats-MHD laeuft erst am Monatsende ab - danach richtet sich die Warnung.
|
||||||
|
function expiryCheckValue(value) {
|
||||||
|
return precision === "month" ? monthInputToLastDay(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null); setInfo(null);
|
setError(null); setInfo(null);
|
||||||
@@ -116,7 +126,11 @@ export default function CheckIn() {
|
|||||||
.filter((l) => Number(l.quantity) > 0)
|
.filter((l) => Number(l.quantity) > 0)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
quantity: Number(l.quantity),
|
quantity: Number(l.quantity),
|
||||||
best_before: l.best_before || null,
|
// Bei Monatsangabe reicht der Monatserste - das Backend legt daraus
|
||||||
|
// den Monatsletzten und merkt sich die Genauigkeit.
|
||||||
|
best_before:
|
||||||
|
(precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null,
|
||||||
|
best_before_precision: precision,
|
||||||
location_id: locationId === "" ? null : Number(locationId),
|
location_id: locationId === "" ? null : Number(locationId),
|
||||||
}));
|
}));
|
||||||
if (payloadLines.length === 0) {
|
if (payloadLines.length === 0) {
|
||||||
@@ -251,7 +265,13 @@ export default function CheckIn() {
|
|||||||
<div className="lines">
|
<div className="lines">
|
||||||
<div className="lines-head">
|
<div className="lines-head">
|
||||||
<span>Chargen</span>
|
<span>Chargen</span>
|
||||||
<span className="muted small">je Charge ein eigenes MHD</span>
|
<label className="precision-pick">
|
||||||
|
<span className="muted small">MHD-Angabe</span>
|
||||||
|
<select value={precision} onChange={(e) => setPrecision(e.target.value)}>
|
||||||
|
<option value="day">Tagesdatum</option>
|
||||||
|
<option value="month">nur Monat/Jahr</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, i) => (
|
||||||
<div className="line" key={i}>
|
<div className="line" key={i}>
|
||||||
@@ -263,10 +283,10 @@ export default function CheckIn() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="grow" style={{ margin: 0 }}>
|
<label className="grow" style={{ margin: 0 }}>
|
||||||
{i === 0 && <span className="line-label">MHD (optional)</span>}
|
{i === 0 && <span className="line-label">MHD (optional)</span>}
|
||||||
<input type="date" value={line.best_before}
|
<input type={precision === "month" ? "month" : "date"} value={line.best_before}
|
||||||
onChange={(e) => setLine(i, "best_before", e.target.value)}
|
onChange={(e) => setLine(i, "best_before", e.target.value)}
|
||||||
className={isExpired(line.best_before) ? "input-danger" : ""} />
|
className={isExpired(expiryCheckValue(line.best_before)) ? "input-danger" : ""} />
|
||||||
{isExpired(line.best_before) && (
|
{isExpired(expiryCheckValue(line.best_before)) && (
|
||||||
<span className="hint-danger"><Icon name="alert" size={12} />bereits abgelaufen</span>
|
<span className="hint-danger"><Icon name="alert" size={12} />bereits abgelaufen</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { formatBestBefore } = 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([]);
|
||||||
@@ -161,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 ${formatDate(l.best_before)}` : "ohne MHD"} · {lotAmount(l.quantity)}
|
{l.best_before ? `MHD ${formatBestBefore(l.best_before, l.best_before_precision)}` : "ohne MHD"} · {lotAmount(l.quantity)}
|
||||||
{isExpired(l.best_before) ? " · abgelaufen" : ""}
|
{isExpired(l.best_before) ? " · abgelaufen" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -172,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 ${formatDate(selectedLot.best_before)}` : " · ohne MHD"}
|
{selectedLot.best_before ? ` · MHD ${formatBestBefore(selectedLot.best_before, selectedLot.best_before_precision)}` : " · ohne MHD"}
|
||||||
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
|
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { formatBestBefore } = 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([]);
|
||||||
@@ -91,7 +91,7 @@ export default function Dashboard() {
|
|||||||
<div className="muted small">{articleSecondary(it)}</div>
|
<div className="muted small">{articleSecondary(it)}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(it.best_before)}</td>
|
<td>{formatBestBefore(it.best_before, it.best_before_precision)}</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)}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ 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 { useSettings } from "../settings";
|
||||||
import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units";
|
import {
|
||||||
|
daysUntil, expiryRowClass, fmt, fromMonthInput, isExpired, relativeExpiry, toMonthInput, unitShort,
|
||||||
|
} from "../units";
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
barcode: "", name: "", brand: "", image_url: "",
|
barcode: "", name: "", brand: "", image_url: "",
|
||||||
unit_id: "", package_size: "", package_label: "", min_stock: "", group_id: "",
|
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", group_id: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
// Auswahl für die Gebinde-Bezeichnung ("Packung" ist der leere Standardwert).
|
||||||
@@ -143,6 +145,7 @@ export default function ProductForm() {
|
|||||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||||
image_url: p.image_url || "", unit_id: uid,
|
image_url: p.image_url || "", unit_id: uid,
|
||||||
package_size: p.package_size ?? "", package_label: p.package_label || "",
|
package_size: p.package_size ?? "", package_label: p.package_label || "",
|
||||||
|
date_precision: p.date_precision === "month" ? "month" : "day",
|
||||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||||
group_id: p.group_id ?? "",
|
group_id: p.group_id ?? "",
|
||||||
});
|
});
|
||||||
@@ -176,6 +179,7 @@ export default function ProductForm() {
|
|||||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||||
package_label: form.package_label.trim() || null,
|
package_label: form.package_label.trim() || null,
|
||||||
|
date_precision: form.date_precision || "day",
|
||||||
min_stock: minBase,
|
min_stock: minBase,
|
||||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||||
min_stock_in_packages: sel === "package",
|
min_stock_in_packages: sel === "package",
|
||||||
@@ -291,7 +295,17 @@ export default function ProductForm() {
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="grow" />
|
<label className="grow">
|
||||||
|
MHD-Angabe
|
||||||
|
<select value={form.date_precision} onChange={(e) => set("date_precision", e.target.value)}
|
||||||
|
disabled={readOnly}>
|
||||||
|
<option value="day">Tagesdatum</option>
|
||||||
|
<option value="month">nur Monat/Jahr</option>
|
||||||
|
</select>
|
||||||
|
<span className="muted small">
|
||||||
|
Voreinstellung beim Einlagern – z.B. Konserven tragen oft nur „09/2026“.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
@@ -378,9 +392,9 @@ 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 { formatBestBefore } = 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: "", precision: "day" });
|
||||||
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
|
// In welcher Einheit die Menge bearbeitet wird: "unit" oder "package".
|
||||||
const [editUnit, setEditUnit] = useState("unit");
|
const [editUnit, setEditUnit] = useState("unit");
|
||||||
|
|
||||||
@@ -410,7 +424,14 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
const mode = pkgSize > 0 ? "package" : "unit";
|
const mode = pkgSize > 0 ? "package" : "unit";
|
||||||
setEditId(l.id);
|
setEditId(l.id);
|
||||||
setEditUnit(mode);
|
setEditUnit(mode);
|
||||||
setDraft({ quantity: l.quantity / editFactorOf(mode), best_before: l.best_before || "" });
|
setDraft({
|
||||||
|
quantity: l.quantity / editFactorOf(mode),
|
||||||
|
best_before:
|
||||||
|
l.best_before_precision === "month"
|
||||||
|
? toMonthInput(l.best_before)
|
||||||
|
: l.best_before || "",
|
||||||
|
precision: l.best_before_precision === "month" ? "month" : "day",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeEditUnit(newMode) {
|
function changeEditUnit(newMode) {
|
||||||
@@ -426,7 +447,10 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
try {
|
try {
|
||||||
await api.updateLot(l.id, {
|
await api.updateLot(l.id, {
|
||||||
quantity: Number(draft.quantity) * editFactor,
|
quantity: Number(draft.quantity) * editFactor,
|
||||||
best_before: draft.best_before || null,
|
best_before:
|
||||||
|
(draft.precision === "month" ? fromMonthInput(draft.best_before) : draft.best_before) ||
|
||||||
|
null,
|
||||||
|
best_before_precision: draft.precision || "day",
|
||||||
});
|
});
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
@@ -498,12 +522,13 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input type="date" value={draft.best_before}
|
<input type={draft.precision === "month" ? "month" : "date"}
|
||||||
|
value={draft.best_before}
|
||||||
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
|
||||||
style={{ marginTop: 0, minWidth: 150 }} />
|
style={{ marginTop: 0, minWidth: 150 }} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{l.best_before ? formatDate(l.best_before) : "–"}
|
{l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : "–"}
|
||||||
{cls && (
|
{cls && (
|
||||||
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
|
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
|
||||||
style={{ marginLeft: 6 }}>
|
style={{ marginLeft: 6 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
import { formatDate } from "./units";
|
import { formatBestBefore, formatDate } from "./units";
|
||||||
|
|
||||||
export const DATE_FORMAT_KEY = "date_format";
|
export const DATE_FORMAT_KEY = "date_format";
|
||||||
const DEFAULT_FORMAT = "de";
|
const DEFAULT_FORMAT = "de";
|
||||||
@@ -9,6 +9,7 @@ const DEFAULT_FORMAT = "de";
|
|||||||
const SettingsContext = createContext({
|
const SettingsContext = createContext({
|
||||||
dateFormat: DEFAULT_FORMAT,
|
dateFormat: DEFAULT_FORMAT,
|
||||||
formatDate: (iso) => formatDate(iso, DEFAULT_FORMAT),
|
formatDate: (iso) => formatDate(iso, DEFAULT_FORMAT),
|
||||||
|
formatBestBefore: (iso, precision) => formatBestBefore(iso, precision, DEFAULT_FORMAT),
|
||||||
reload: () => {},
|
reload: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ export function SettingsProvider({ children }) {
|
|||||||
const value = {
|
const value = {
|
||||||
dateFormat,
|
dateFormat,
|
||||||
formatDate: (iso) => formatDate(iso, dateFormat),
|
formatDate: (iso) => formatDate(iso, dateFormat),
|
||||||
|
// MHD: Monatsangaben erscheinen als "09/2026", Tagesdaten im gewaehlten Format.
|
||||||
|
formatBestBefore: (iso, precision) => formatBestBefore(iso, precision, dateFormat),
|
||||||
reload,
|
reload,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,9 @@ td select { width: auto; min-width: 132px; max-width: 100%; }
|
|||||||
.line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); }
|
.line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); }
|
||||||
.line .btn-icon { margin-bottom: 1px; }
|
.line .btn-icon { margin-bottom: 1px; }
|
||||||
.line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; }
|
.line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; }
|
||||||
|
/* Umschalter Tagesdatum / Monat im Kopf der Chargenliste */
|
||||||
|
.precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; }
|
||||||
|
.precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; }
|
||||||
.input-danger { border-color: var(--danger) !important; }
|
.input-danger { border-color: var(--danger) !important; }
|
||||||
.hint-danger { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 0.72rem; margin-top: 3px; }
|
.hint-danger { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 0.72rem; margin-top: 3px; }
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,38 @@ export function formatDate(iso, format = "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 <input type="month">. */
|
||||||
|
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".
|
// 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user