Darstellung der App nachgebessert, Gruppen heissen jetzt Kategorien

Die App zeigte an mehreren Stellen Rohwerte statt lesbarer Angaben, und die
Bedienelemente sahen nicht nach Bedienelementen aus.

Deutsche Lokalisierung: Im Chargen-Abschnitt stand "10. Dec 2027" - deutsches
Format mit englischem Monatsnamen. Die Ursache lag nicht in der Formatierung,
sondern darin, dass die App als englische App gebaut wurde
(CFBundleDevelopmentRegion = $(DEVELOPMENT_LANGUAGE), also "en"). Jetzt ist
Deutsch als Sprache gesetzt; damit zeichnet iOS DatePicker und Monatsnamen von
selbst richtig. Der MonthYearPicker legt seinen Kalender zusaetzlich fest auf
de_DE, damit die Monatsnamen auch bei anderssprachigem Geraet stimmen.

Datumsformat: Die Anzeige folgt jetzt der Servereinstellung (Einstellungen ->
Darstellung), wie die Web-Oberflaeche. Neu DisplaySettings mit den fuenf
Formaten, den deutschen Beschriftungen der Basiseinheiten und der Mengenangabe
in der Leitangabe. Die doppelt ausprogrammierte MHD-Formatierung in
ListViews und CheckOutView ist damit weg.

Chargen-Abschnitt: Jede Charge steht jetzt in einem eigenen Abschnitt mit
Kopfzeile statt gestapelt in einer einzigen Formularzeile - das war der
gequetschte Eindruck. Loeschen sitzt in der Kopfzeile.

Mengenfelder haben einen sichtbaren Rahmen. Ohne ihn las sich das
rechtsbuendige Feld wie fester Text, und es war nicht erkennbar, dass sich die
Menge ueberhaupt aendern laesst.

Kamera: Der Sucher fuellte per ZStack und ignoresSafeArea den ganzen Bildschirm
und wirkte dadurch erdrueckend. Er sitzt jetzt als 4:3-Feld oben, darunter
Hinweis, Meldungen und Knoepfe in gewohnter Form. Am ScannerViewController
musste nichts geaendert werden.

Einkaufsliste: Artikel und Kategorien tragen ein Symbol, Kategoriezeilen
zusaetzlich eine Kennzeichnung. Den Artikelzeilen fehlte bisher jede Einheit
("fehlt 2 - Bestand 7 von 10"); sie nennen jetzt Packungen und Basismenge.

Produktdetails: Die Felder waren blanke TextFields - sobald ein Wert drinstand,
verschwand der Platzhalter und man las nur noch "Bratbutter" / "M-Classic" /
"450". Jetzt mit Beschriftung links, Einheit hinter der Packungsgroesse (vorher
stand dort der rohe API-Wert "gram") und einem Abschnitt "Erkennung" mit
Barcode. Dasselbe im Formular zum Anlegen.

Gruppen heissen in der Oberflaeche jetzt Kategorien - in Web und App. Tabelle,
Feld group_id und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe
wie Home Assistant weiterlaufen; der Unterschied ist rein sprachlich. Die
Kategorie laesst sich nun auch in der App zuweisen, was vorher gar nicht ging.

Dabei ein Fehler gefunden und behoben: Swift laesst nil-Optionals beim Kodieren
weg, das Backend wertet nur mitgeschickte Felder aus. "Keine Kategorie" oder
eine geloeschte Marke waeren damit stumm verpufft. ProductUpdateRequest kodiert
diese Felder jetzt ausdruecklich. Der Mindestbestand steht bewusst nicht mehr
darin, weil die App ihn nicht bearbeitet und ein mitgesendetes null ihn
geloescht haette.

Getestet: iOS-Geraetebuild fehler- und warnungsfrei, Web-Build laeuft durch,
40 pytest-Tests gruen. Die Datumsformatierung wurde eigenstaendig uebersetzt
und gegen elf Faelle geprueft (alle fuenf Formate, Monatsangabe, fehlendes und
unlesbares Datum) - alle korrekt. Im gebauten Bundle ist belegt, dass
CFBundleDevelopmentRegion auf "de" steht. Gegen die laufende API geprueft:
/settings und /groups liefern die erwarteten Felder, das Leeren der Kategorie
wirkt jetzt und der Mindestbestand bleibt dabei erhalten.
Die Oberflaeche selbst habe ich nicht auf dem Geraet bedient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 19:04:58 +02:00
parent f3ee10e4d4
commit 47ca4802aa
22 changed files with 420 additions and 150 deletions

View File

@@ -39,13 +39,24 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
- **Migration:** schonendes Nachziehen neuer Spalten beim Start - **Migration:** schonendes Nachziehen neuer Spalten beim Start
(`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten. (`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten.
## Schritt 2 Native iOS-App (SwiftUI) offen ## Schritt 2 Native iOS-App (SwiftUI)
- Login (Server-URL + Passwort, Token im Keychain). - Login (Server-URL + Passwort, Token im Keychain), „angemeldet bleiben“ abwählbar.
- Barcode-Scan (VisionKit / AVFoundation). - Barcode-Scan (AVFoundation) mit Nahbereich-Autofokus, Licht und Tippen zum Scharfstellen.
- Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD (mehrere Chargen). - Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD (mehrere Chargen).
- **MHD per Texterkennung** ablesbar (VisionKit); erkannte Daten werden vorgeschlagen,
nie ungefragt übernommen.
- Auslagern-Flow: scannen → Menge/Einheit, FEFO oder Chargenauswahl. - Auslagern-Flow: scannen → Menge/Einheit, FEFO oder Chargenauswahl.
- Einkaufsliste- und „bald ablaufend“-Ansicht. - Einkaufsliste, „bald ablaufend“ und Produktliste mit Bearbeiten.
- Produkte bei Bedarf direkt in der App anlegen. - Home-Screen-Shortcuts und URL-Schema `projectgood://checkin` / `checkout`.
- Anzeige folgt dem Datumsformat aus den Servereinstellungen.
## Schritt 2b MHD-Genauigkeit und Benennung ✅
- **MHD wahlweise nur mit Monat und Jahr.** Gespeichert wird weiterhin ein echtes
Datum (Monatsletzter), damit FEFO unverändert arbeitet; die Genauigkeit steht
getrennt an Charge und Produkt. Export/Import bleiben verlustfrei.
- **Gruppen heißen in der Oberfläche jetzt Kategorien.** Tabelle, Feld `group_id`
und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe weiterlaufen.
- **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung).
## Schritt 3 Weitere Ideen offen ## Schritt 3 Weitere Ideen offen
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).

View File

@@ -3,10 +3,10 @@
Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe
REST-API wie die Web-Oberfläche. REST-API wie die Web-Oberfläche.
> **Stand:** Erste lauffähige Fassung (Login, Scanner, Einlagern mit mehreren > **Stand:** Läuft auf dem Gerät. Login, Scanner, Einlagern mit mehreren
> Chargen/MHDs, Auslagern mit Chargenauswahl, Artikel anlegen aus Open Food Facts). > Chargen/MHDs, MHD per Texterkennung, Auslagern mit Chargenauswahl, Artikel
> Sie wurde bisher **nicht in Xcode kompiliert** auf dem Entwicklungsrechner ist > anlegen aus Open Food Facts, Einkaufsliste, Ablaufliste sowie Produkte
> kein macOS/Xcode vorhanden. Rechne beim ersten Build mit Kleinigkeiten. > ansehen und bearbeiten.
## Projekt in Xcode öffnen ## Projekt in Xcode öffnen
@@ -65,10 +65,17 @@ Beide Wege öffnen die App **direkt in der Kamera**.
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD | | `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD |
| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO | | `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) | | `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) |
| `ProductDetailView.swift` | Produkt bearbeiten, Chargen korrigieren und löschen |
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste |
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen |
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
## Kategorien
Was in der Datenbank und in der Schnittstelle `group` heißt, trägt in der
Oberfläche den Namen **Kategorie** („Molkereiprodukte", „Brot", …). Der Name in
API und Tabelle blieb bewusst unverändert, damit bestehende Zugriffe etwa aus
Home Assistant weiterlaufen.
## Noch offen ## Noch offen
- Einkaufsliste und „bald ablaufend" in der App
- Gruppenauswahl beim Anlegen (aktuell wird die Gruppe aus dem gescannten
EAN-Code übernommen, falls hinterlegt)
- Push-Benachrichtigungen bei ablaufenden Produkten - Push-Benachrichtigungen bei ablaufenden Produkten
- App-Icon - Lagerort je Charge beim Einlagern wählbar

View File

@@ -113,6 +113,14 @@ actor APIClient {
return try await send(request, as: Product.self) return try await send(request, as: Product.self)
} }
func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self)
}
func settings() async throws -> [SettingEntry] {
try await send(try makeRequest("/settings"), as: [SettingEntry].self)
}
// MARK: - Listen // MARK: - Listen
func shoppingList() async throws -> [ShoppingItem] { func shoppingList() async throws -> [ShoppingItem] {

View File

@@ -6,6 +6,52 @@ struct LineRef: Identifiable {
let id: UUID let id: UUID
} }
/// Textzeile mit Beschriftung links und sichtbarem Rahmen.
struct LabeledField: View {
let label: String
@Binding var text: String
var keyboard: UIKeyboardType = .default
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 190)
}
}
}
/// Zahleneingabe mit Beschriftung links und sichtbarem Rahmen.
///
/// Ein rechtsbuendiges Textfeld ohne Rahmen liest sich wie fester Text - beim
/// Testen war nicht erkennbar, dass sich die Menge ueberhaupt aendern laesst.
struct QuantityField: View {
let label: String
@Binding var text: String
var suffix: String?
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 110)
if let suffix {
Text(suffix)
.foregroundStyle(.secondary)
.frame(minWidth: 60, alignment: .leading)
}
}
}
}
extension Date { extension Date {
/// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt. /// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt.
var startOfMonth: Date { var startOfMonth: Date {
@@ -20,7 +66,13 @@ extension Date {
struct MonthYearPicker: View { struct MonthYearPicker: View {
@Binding var date: Date @Binding var date: Date
private let calendar = Calendar.current // Fest deutsch, damit die Monatsnamen auch dann stimmen, wenn das Geraet
// auf einer anderen Sprache steht.
private var calendar: Calendar {
var kalender = Calendar(identifier: .gregorian)
kalender.locale = Locale(identifier: "de_DE")
return kalender
}
private var monthNames: [String] { calendar.monthSymbols } private var monthNames: [String] { calendar.monthSymbols }
private var years: [Int] { private var years: [Int] {
@@ -139,26 +191,15 @@ struct CheckInFormView: View {
} }
} }
Section("Chargen") { // Jede Charge bekommt einen eigenen Abschnitt. Vorher standen alle
ForEach($lines) { $line in // Zeilen gestapelt in einer einzigen Formularzeile und wirkten
VStack(alignment: .leading, spacing: 6) { // dadurch gequetscht.
HStack { ForEach(Array($lines.enumerated()), id: \.element.id) { index, $line in
// Ohne Beschriftung sah die vorbelegte 1 wie eine Section {
// Nummerierung der Charge aus. QuantityField(label: "Menge", text: $line.quantity)
Text("Menge")
Spacer(minLength: 12)
TextField("Menge", text: $line.quantity)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 110)
if lines.count > 1 {
Button(role: .destructive) {
lines.removeAll { $0.id == line.id }
} label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
Toggle("MHD angeben", isOn: $line.hasDate) Toggle("MHD angeben", isOn: $line.hasDate)
if line.hasDate { if line.hasDate {
if precision == "month" { if precision == "month" {
MonthYearPicker(date: $line.bestBefore) MonthYearPicker(date: $line.bestBefore)
@@ -170,14 +211,27 @@ struct CheckInFormView: View {
scanLineId = LineRef(id: line.id) scanLineId = LineRef(id: line.id)
} label: { } label: {
Label("MHD scannen", systemImage: "text.viewfinder") Label("MHD scannen", systemImage: "text.viewfinder")
.font(.callout) }
}
}
} header: {
HStack {
Text(lines.count > 1 ? "Charge \(index + 1)" : "Charge")
Spacer()
if lines.count > 1 {
Button(role: .destructive) {
lines.removeAll { $0.id == line.id }
} label: {
Label("Entfernen", systemImage: "trash")
.labelStyle(.iconOnly)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
} }
} }
.padding(.vertical, 2)
} }
Section {
Button { Button {
lines.append(Line()) lines.append(Line())
} label: { } label: {

View File

@@ -18,12 +18,19 @@ struct CheckInView: View {
@State private var locations: [StorageLocation] = [] @State private var locations: [StorageLocation] = []
var body: some View { var body: some View {
ZStack(alignment: .bottom) { // Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
// Sucher ueber die ganze Flaeche wirkte erschlagend.
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } }, ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn) isPaused: $paused, torchOn: $torchOn)
.ignoresSafeArea() .aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten")
.font(.caption).foregroundStyle(.secondary)
VStack(spacing: 10) {
if let status { banner(status, color: .green) } if let status { banner(status, color: .green) }
if let error { banner(error, color: .red) } if let error { banner(error, color: .red) }
@@ -33,7 +40,7 @@ struct CheckInView: View {
unknownCard(unknownCode) unknownCard(unknownCode)
} }
HStack(spacing: 10) { VStack(spacing: 10) {
Button { Button {
paused = true paused = true
manualCodeShown = true manualCodeShown = true
@@ -54,10 +61,8 @@ struct CheckInView: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 8)
} }
.padding(.bottom, 4) .padding(.vertical)
.background(.ultraThinMaterial.opacity(0.001))
} }
.navigationTitle("Einlagern") .navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@@ -14,16 +14,22 @@ struct CheckOutView: View {
@State private var searchShown = false @State private var searchShown = false
var body: some View { var body: some View {
ZStack(alignment: .bottom) { // Kamera als Feld oben, siehe CheckInView.
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } }, ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn) isPaused: $paused, torchOn: $torchOn)
.ignoresSafeArea() .aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten")
.font(.caption).foregroundStyle(.secondary)
VStack(spacing: 10) {
if let status { banner(status, color: .green) } if let status { banner(status, color: .green) }
if let error { banner(error, color: .red) } if let error { banner(error, color: .red) }
HStack(spacing: 10) { VStack(spacing: 10) {
Button { Button {
paused = true paused = true
searchShown = true searchShown = true
@@ -43,8 +49,8 @@ struct CheckOutView: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 8)
} }
.padding(.vertical)
} }
.navigationTitle("Auslagern") .navigationTitle("Auslagern")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -119,6 +125,7 @@ struct CheckOutFormView: View {
var onDone: (String) -> Void var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
@State private var quantity = "1" @State private var quantity = "1"
@State private var unit = "" @State private var unit = ""
@@ -159,14 +166,7 @@ struct CheckOutFormView: View {
} }
Section("Menge") { Section("Menge") {
HStack { QuantityField(label: "Menge", text: $quantity)
Text("Menge")
Spacer(minLength: 12)
TextField("Menge", text: $quantity)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 110)
}
Picker("Einheit", selection: $unit) { Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) } ForEach(unitOptions) { Text($0.label).tag($0.value) }
} }
@@ -194,16 +194,7 @@ 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)
return "\(bestBeforeText(lot)) · \(amount) \(product.articleUnitLabel)" return "\(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision)) · \(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 {

View File

@@ -0,0 +1,88 @@
import Foundation
/// Anzeigeeinstellungen, die am Server hinterlegt sind.
///
/// Das Datumsformat ist in der Web-Oberflaeche unter Einstellungen ->
/// Darstellung waehlbar. Die App liest denselben Wert, damit beide Oberflaechen
/// gleich aussehen.
@MainActor
final class DisplaySettings: ObservableObject {
static let shared = DisplaySettings()
static let dateFormatKey = "date_format"
static let fallbackFormat = "de"
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
private init() {}
/// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
/// nicht scheitern.
func load() async {
guard let entries = try? await APIClient.shared.settings() else { return }
if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
!entry.value.isEmpty {
dateFormat = entry.value
}
}
// MARK: - Datum
/// MHD lesbar machen. Monatsangaben immer ohne Tag ("09/2026"), sonst nach
/// dem eingestellten Format.
func formatBestBefore(_ raw: String?, precision: String?) -> String {
guard let raw, !raw.isEmpty else { return "ohne MHD" }
guard let date = DisplaySettings.isoParser.date(from: raw) else { return raw }
let muster = precision == "month" ? "MM/yyyy" : DisplaySettings.pattern(for: dateFormat)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.dateFormat = muster
return formatter.string(from: date)
}
/// Entspricht den Formaten aus web/src/units.js (DATE_FORMATS).
static func pattern(for format: String) -> String {
switch format {
case "de-short": return "dd.MM.yy"
case "long": return "d. MMMM yyyy"
case "iso": return "yyyy-MM-dd"
case "us": return "MM/dd/yyyy"
default: return "dd.MM.yyyy" // "de"
}
}
private static let isoParser: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
// MARK: - Einheiten
/// Kanonische Basiseinheit der API auf Deutsch.
func baseUnitLabel(_ raw: String?) -> String {
DisplaySettings.baseUnitLabel(raw)
}
static func baseUnitLabel(_ raw: String?) -> String {
switch raw {
case "piece": return "Stück"
case "gram": return "Gramm"
case "milliliter": return "Milliliter"
default: return raw ?? ""
}
}
/// Menge in der Leitangabe: Gibt es ein Gebinde, zaehlt das, die Basismenge
/// steht dahinter in Klammern (wie amountText in der Web-Oberflaeche).
func amountText(_ quantity: Double, packageSize: Double?, baseUnit: String?) -> String {
let einheit = DisplaySettings.baseUnitLabel(baseUnit)
if let size = packageSize, size > 0 {
return "\(formatAmount(quantity / size)) Pkg. (\(formatAmount(quantity)) \(einheit))"
}
return "\(formatAmount(quantity)) \(einheit)"
}
}

View File

@@ -3,8 +3,14 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Von Xcode aus den Build-Einstellungen gefuellt --> <!-- Von Xcode aus den Build-Einstellungen gefuellt -->
<!-- Deutsch als Sprache der App: Sonst zeichnet iOS DatePicker und
Monatsnamen auf Englisch ("10. Dec 2027"). -->
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>de</string>
<key>CFBundleLocalizations</key>
<array>
<string>de</string>
</array>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>

View File

@@ -5,18 +5,49 @@ func formatAmount(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
} }
/// MHD lesbar: Monatsangaben ohne Tag ("09/2026"). /// Kennzeichnung "Kategorie" an einer Listenzeile.
func formatBestBefore(_ raw: String?, precision: String?) -> String { struct CategoryBadge: View {
guard let raw else { return "ohne MHD" } var body: some View {
guard precision == "month" else { return raw } Text("Kategorie")
let teile = raw.split(separator: "-") .font(.caption2)
guard teile.count >= 2 else { return raw } .padding(.horizontal, 6)
return "\(teile[1])/\(teile[0])" .padding(.vertical, 2)
.background(Color.secondary.opacity(0.15))
.clipShape(Capsule())
.foregroundStyle(.secondary)
}
}
/// Zeile mit Symbol links, damit Artikel und Kategorie unterscheidbar sind.
struct ListRow<Trailing: View>: View {
let systemImage: String
let title: String
let subtitle: String
@ViewBuilder var trailing: Trailing
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(title)
trailing
}
Text(subtitle)
.font(.caption).foregroundStyle(.secondary)
}
}
}
} }
// MARK: - Einkaufsliste // MARK: - Einkaufsliste
struct ShoppingListView: View { struct ShoppingListView: View {
@EnvironmentObject private var display: DisplaySettings
@State private var items: [ShoppingItem] = [] @State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = [] @State private var groups: [GroupShoppingItem] = []
@State private var busy = true @State private var busy = true
@@ -30,22 +61,22 @@ struct ShoppingListView: View {
if !items.isEmpty { if !items.isEmpty {
Section("Artikel unter Mindestbestand") { Section("Artikel unter Mindestbestand") {
ForEach(items) { item in ForEach(items) { item in
VStack(alignment: .leading, spacing: 2) { ListRow(
Text(item.name) systemImage: "shippingbox",
Text("fehlt \(formatAmount(item.deficit)) · Bestand \(formatAmount(item.stock)) von \(formatAmount(item.minStock))") title: item.name,
.font(.caption).foregroundStyle(.secondary) subtitle: "fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))"
} ) { EmptyView() }
} }
} }
} }
if !groups.isEmpty { if !groups.isEmpty {
Section("Gruppen unter Mindestbestand") { Section("Kategorien unter Mindestbestand") {
ForEach(groups) { group in ForEach(groups) { group in
VStack(alignment: .leading, spacing: 2) { ListRow(
Text(group.name) systemImage: "square.stack.3d.up",
Text("fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel") title: group.name,
.font(.caption).foregroundStyle(.secondary) subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
} ) { CategoryBadge() }
} }
} }
} }
@@ -76,6 +107,8 @@ struct ShoppingListView: View {
// MARK: - Bald ablaufend // MARK: - Bald ablaufend
struct ExpiringView: View { struct ExpiringView: View {
@EnvironmentObject private var display: DisplaySettings
@State private var items: [ExpiringItem] = [] @State private var items: [ExpiringItem] = []
@State private var busy = true @State private var busy = true
@State private var error: String? @State private var error: String?
@@ -89,7 +122,7 @@ struct ExpiringView: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.productName) Text(item.productName)
Text("MHD \(formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))") Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
@@ -148,11 +181,11 @@ struct ProductListView: View {
NavigationLink { NavigationLink {
ProductDetailView(product: product) ProductDetailView(product: product)
} label: { } label: {
VStack(alignment: .leading, spacing: 2) { ListRow(
Text(product.name) systemImage: "shippingbox",
Text("\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)") title: product.name,
.font(.caption).foregroundStyle(.secondary) subtitle: "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
} ) { EmptyView() }
} }
} }
if products.isEmpty && !busy { if products.isEmpty && !busy {

View File

@@ -303,21 +303,38 @@ struct ExpiringItem: Codable, Identifiable {
// MARK: - Aenderungen // MARK: - Aenderungen
/// Nur gesetzte Felder werden geschickt (PATCH). /// Die Felder, die das Produktformular der App besitzt (PATCH).
struct ProductUpdateRequest: Codable { ///
/// Bewusst mit eigener Kodierung: Swift laesst `nil`-Optionals sonst einfach
/// weg, und das Backend wertet nur mitgeschickte Felder aus. Leeren waere damit
/// unmoeglich - "keine Kategorie" oder eine geloeschte Marke wuerden stumm
/// verpuffen. Felder, die die App nicht bearbeitet (etwa der Mindestbestand),
/// stehen deshalb gar nicht erst hier drin.
struct ProductUpdateRequest: Encodable {
var name: String? var name: String?
var brand: String? var brand: String?
var packageSize: Double? var packageSize: Double?
var packageLabel: String? var packageLabel: String?
var datePrecision: String? var datePrecision: String?
var minStock: Double? /// Kategorie (heisst in der API weiterhin group_id).
var groupId: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name, brand case name, brand
case packageSize = "package_size" case packageSize = "package_size"
case packageLabel = "package_label" case packageLabel = "package_label"
case datePrecision = "date_precision" case datePrecision = "date_precision"
case minStock = "min_stock" case groupId = "group_id"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(brand, forKey: .brand)
try container.encode(packageSize, forKey: .packageSize)
try container.encode(packageLabel, forKey: .packageLabel)
try container.encode(datePrecision, forKey: .datePrecision)
try container.encode(groupId, forKey: .groupId)
} }
} }
@@ -332,3 +349,16 @@ struct LotUpdateRequest: Codable {
case bestBeforePrecision = "best_before_precision" case bestBeforePrecision = "best_before_precision"
} }
} }
/// Ein Eintrag aus /settings (Schluessel/Wert).
struct SettingEntry: Codable {
let key: String
let value: String
}
/// Kategorie (heisst in der Datenbank und in der API weiterhin "group";
/// nur die Beschriftung in der Oberflaeche wurde auf "Kategorie" geaendert).
struct GroupItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
}

View File

@@ -4,6 +4,8 @@ import SwiftUI
struct ProductDetailView: View { struct ProductDetailView: View {
let product: Product let product: Product
@EnvironmentObject private var display: DisplaySettings
@State private var current: Product @State private var current: Product
@State private var lots: [Lot] = [] @State private var lots: [Lot] = []
@State private var busy = false @State private var busy = false
@@ -16,6 +18,8 @@ struct ProductDetailView: View {
@State private var packageSize = "" @State private var packageSize = ""
@State private var packageLabel = "" @State private var packageLabel = ""
@State private var datePrecision = "day" @State private var datePrecision = "day"
@State private var groupId: Int?
@State private var categories: [GroupItem] = []
@State private var editLot: Lot? @State private var editLot: Lot?
@@ -44,20 +48,35 @@ struct ProductDetailView: View {
} }
} }
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
Section("Artikel") { Section("Artikel") {
TextField("Name", text: $name) LabeledField(label: "Name", text: $name)
TextField("Marke", text: $brand) LabeledField(label: "Marke", text: $brand)
TextField("Packungsgröße (in \(current.baseUnit))", text: $packageSize) QuantityField(label: "Packungsgröße", text: $packageSize,
.keyboardType(.decimalPad) suffix: display.baseUnitLabel(current.baseUnit))
Picker("Bezeichnung", selection: $packageLabel) { Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
} }
Picker("Kategorie", selection: $groupId) {
Text(" keine ").tag(Int?.none)
ForEach(categories) { kategorie in
Text(kategorie.name).tag(Int?.some(kategorie.id))
}
}
Picker("MHD-Angabe", selection: $datePrecision) { Picker("MHD-Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day") Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month") Text("nur Monat/Jahr").tag("month")
} }
} }
Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "")
if !current.barcodes.isEmpty {
LabeledContent("Weitere Codes", value: "\(current.barcodes.count)")
}
}
Section { Section {
Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } } Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } }
.disabled(busy || name.isEmpty) .disabled(busy || name.isEmpty)
@@ -70,7 +89,7 @@ struct ProductDetailView: View {
} label: { } label: {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("MHD \(formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
@@ -109,9 +128,11 @@ struct ProductDetailView: View {
packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? "" packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day" datePrecision = current.datePrecision == "month" ? "month" : "day"
groupId = current.groupId
} }
private func reload() async { private func reload() async {
categories = (try? await APIClient.shared.groups()) ?? []
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
if let frisch = try? await APIClient.shared.product(id: current.id) { if let frisch = try? await APIClient.shared.product(id: current.id) {
current = frisch current = frisch
@@ -132,7 +153,7 @@ struct ProductDetailView: View {
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel, packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision, datePrecision: datePrecision,
minStock: nil groupId: groupId
) )
) )
status = "Gespeichert." status = "Gespeichert."
@@ -172,14 +193,8 @@ struct LotEditView: View {
var body: some View { var body: some View {
Form { Form {
Section("Menge") { Section("Menge") {
HStack { QuantityField(label: "Menge", text: $quantity,
Text(product.articleUnitLabel) suffix: product.articleUnitLabel)
Spacer(minLength: 12)
TextField("Menge", text: $quantity)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 110)
}
} }
Section("MHD") { Section("MHD") {

View File

@@ -65,25 +65,34 @@ struct ProductFormView: View {
@State private var packageSize = "" @State private var packageSize = ""
@State private var packageLabel = "" @State private var packageLabel = ""
@State private var datePrecision = "day" @State private var datePrecision = "day"
@State private var selectedGroupId: Int?
@State private var categories: [GroupItem] = []
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")] private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
var body: some View { var body: some View {
Form { Form {
Section("Artikel") { Section("Artikel") {
TextField("Barcode", text: $barcode).keyboardType(.numberPad) LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
TextField("Name", text: $name) LabeledField(label: "Name", text: $name)
TextField("Marke", text: $brand) LabeledField(label: "Marke", text: $brand)
Picker("Kategorie", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
ForEach(categories) { kategorie in
Text(kategorie.name).tag(Int?.some(kategorie.id))
}
}
} }
Section("Einheit") { Section("Einheit") {
Picker("Basiseinheit", selection: $baseUnit) { Picker("Basiseinheit", selection: $baseUnit) {
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
} }
TextField("Packungsgröße (in Basiseinheit)", text: $packageSize) QuantityField(label: "Packungsgröße", text: $packageSize,
.keyboardType(.decimalPad) suffix: DisplaySettings.baseUnitLabel(baseUnit))
Picker("Bezeichnung", selection: $packageLabel) { Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
} }
@@ -105,10 +114,13 @@ struct ProductFormView: View {
.navigationTitle("Neuer Artikel") .navigationTitle("Neuer Artikel")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: prefill) .onAppear(perform: prefill)
.task { categories = (try? await APIClient.shared.groups()) ?? [] }
} }
private func prefill() { private func prefill() {
barcode = suggestion?.barcode ?? prefillBarcode ?? "" barcode = suggestion?.barcode ?? prefillBarcode ?? ""
// Aus dem gescannten Code vorgeschlagene Kategorie uebernehmen.
selectedGroupId = groupId
if let suggestion { if let suggestion {
name = suggestion.name name = suggestion.name
brand = suggestion.brand ?? "" brand = suggestion.brand ?? ""
@@ -132,7 +144,7 @@ struct ProductFormView: View {
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel, packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision, datePrecision: datePrecision,
groupId: groupId groupId: selectedGroupId
) )
) )
onCreated(product) onCreated(product)

View File

@@ -28,12 +28,14 @@ struct ProjectGoodApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var session = Session.shared @StateObject private var session = Session.shared
@StateObject private var router = Router() @StateObject private var router = Router()
@StateObject private var display = DisplaySettings.shared
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environmentObject(session) .environmentObject(session)
.environmentObject(router) .environmentObject(router)
.environmentObject(display)
.onOpenURL { router.handle(url: $0) } .onOpenURL { router.handle(url: $0) }
.onAppear { .onAppear {
if let type = AppDelegate.pendingShortcut { if let type = AppDelegate.pendingShortcut {

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct RootView: View { struct RootView: View {
@EnvironmentObject private var session: Session @EnvironmentObject private var session: Session
@EnvironmentObject private var router: Router @EnvironmentObject private var router: Router
@EnvironmentObject private var display: DisplaySettings
var body: some View { var body: some View {
Group { Group {
@@ -12,6 +13,10 @@ struct RootView: View {
LoginView() LoginView()
} }
} }
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server.
.task(id: session.isLoggedIn) {
if session.isLoggedIn { await display.load() }
}
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
.fullScreenCover(item: $router.route) { route in .fullScreenCover(item: $router.route) { route in
NavigationStack { NavigationStack {

View File

@@ -5,6 +5,9 @@
name: ProjectGood name: ProjectGood
options: options:
bundleIdPrefix: com.scarriffle bundleIdPrefix: com.scarriffle
# Ohne das baut Xcode eine englische App: DatePicker und Monatsnamen
# erscheinen dann als "10. Dec 2027" statt "10. Dez. 2027".
developmentLanguage: de
deploymentTarget: deploymentTarget:
iOS: "16.0" iOS: "16.0"
createIntermediateGroups: true createIntermediateGroups: true

View File

@@ -47,7 +47,7 @@ function Sidebar() {
<NavItem to="/checkin" icon="checkin" label="Einlagern" /> <NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" /> <NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" /> <NavItem to="/products" icon="package" label="Produkte" />
<NavItem to="/groups" icon="tag" label="Gruppen" /> <NavItem to="/groups" icon="tag" label="Kategorien" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" /> <NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/history" icon="history" label="Verlauf" /> <NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && ( {isAdmin && (

View File

@@ -13,7 +13,7 @@ export default function CheckIn() {
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [suggestion, setSuggestion] = useState(null); const [suggestion, setSuggestion] = useState(null);
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl"). // Kategorie, die diesem Code zugeordnet ist (heisst in der API weiterhin "group").
const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" }); const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" });
const [unknownBarcode, setUnknownBarcode] = useState(null); const [unknownBarcode, setUnknownBarcode] = useState(null);
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
@@ -80,7 +80,7 @@ export default function CheckIn() {
async function createFromSuggestion() { async function createFromSuggestion() {
setError(null); setBusy(true); setError(null); setBusy(true);
try { try {
// Ist der Code einer Gruppe zugeordnet, hat das Vorrang vor dem Kategorie-Rateversuch. // Ist der Code einer Kategorie zugeordnet, hat das Vorrang vor dem Rateversuch aus OFF.
const groupId = suggestionGroup.id || guessGroup(groups, suggestion); const groupId = suggestionGroup.id || guessGroup(groups, suggestion);
const payload = suggestionToProduct(suggestion, groupId); const payload = suggestionToProduct(suggestion, groupId);
const created = await api.createProduct(payload); const created = await api.createProduct(payload);
@@ -185,7 +185,7 @@ export default function CheckIn() {
</div> </div>
<div className="muted small"> <div className="muted small">
Noch nicht im Katalog. Noch nicht im Katalog.
{suggestionGroup.name && ` Gruppe: ${suggestionGroup.name}`} {suggestionGroup.name && ` Kategorie: ${suggestionGroup.name}`}
</div> </div>
</div> </div>
{isAdmin ? ( {isAdmin ? (

View File

@@ -120,7 +120,7 @@ export default function Dashboard() {
<tbody> <tbody>
{groupShopping.map((it) => ( {groupShopping.map((it) => (
<tr key={`g${it.group_id}`}> <tr key={`g${it.group_id}`}>
<td><span className="badge accent">Gruppe</span> {it.name}</td> <td><span className="badge accent">Kategorie</span> {it.name}</td>
<td className="num">{fmt(it.stock)} {it.unit_name}</td> <td className="num">{fmt(it.stock)} {it.unit_name}</td>
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td> <td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr> </tr>

View File

@@ -52,7 +52,7 @@ export default function Groups() {
} }
async function remove(group) { async function remove(group) {
if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Gruppenzuordnung.`)) return; if (!confirm(`Kategorie "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return;
try { try {
await api.deleteGroup(group.id); await api.deleteGroup(group.id);
if (selectedId === group.id) setSelectedId(null); if (selectedId === group.id) setSelectedId(null);
@@ -68,9 +68,9 @@ export default function Groups() {
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Gruppen</h1> <h1>Kategorien</h1>
<div className="sub"> <div className="sub">
Produkte zusammenfassen, Gruppen-Mindestbestand setzen und EAN-Codes hinterlegen Produkte zusammenfassen, Mindestbestand je Kategorie setzen und EAN-Codes hinterlegen
</div> </div>
</div> </div>
</div> </div>
@@ -82,7 +82,7 @@ export default function Groups() {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
<th>Gruppe</th> <th>Kategorie</th>
<th className="num">Produkte</th> <th className="num">Produkte</th>
<th className="num">Bestand</th> <th className="num">Bestand</th>
<th>Mindestbestand</th> <th>Mindestbestand</th>
@@ -146,7 +146,7 @@ export default function Groups() {
</tr> </tr>
); );
})} })}
{groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Gruppen.</td></tr>} {groups.length === 0 && <tr><td colSpan={7} className="empty">Noch keine Kategorien.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -162,7 +162,7 @@ export default function Groups() {
<BarcodeList <BarcodeList
barcodes={selected.barcodes || []} barcodes={selected.barcodes || []}
disabled={!isAdmin} disabled={!isAdmin}
hint="Scannst du einen dieser Codes beim Einlagern, wird das neue Produkt automatisch dieser Gruppe zugeordnet." hint="Scannst du einen dieser Codes beim Einlagern, wird das neue Produkt automatisch dieser Kategorie zugeordnet."
onAdd={async (body) => { onAdd={async (body) => {
try { try {
await api.addGroupBarcode(selected.id, body); await api.addGroupBarcode(selected.id, body);
@@ -182,7 +182,7 @@ export default function Groups() {
{isAdmin && ( {isAdmin && (
<form className="card" onSubmit={add}> <form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div> <div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
<label> <label>
Name Name
<input placeholder="z.B. Mehl" value={form.name} <input placeholder="z.B. Mehl" value={form.name}
@@ -205,7 +205,7 @@ export default function Groups() {
</div> </div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small"> <p className="muted small">
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen. Der Bestand der Kategorie summiert nur Produkte, die zur gewählten Einheit passen.
Namen lassen sich in der Tabelle direkt ändern. Namen lassen sich in der Tabelle direkt ändern.
</p> </p>
</form> </form>

View File

@@ -92,7 +92,7 @@ export default function ProductForm() {
setInfo( setInfo(
"Daten von Open Food Facts übernommen." + "Daten von Open Food Facts übernommen." +
(s.package_size != null ? "" : " (Füllmenge nicht hinterlegt bitte Packungsgröße prüfen.)") + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt bitte Packungsgröße prüfen.)") +
(groupGuess ? " Passende Gruppe vorgeschlagen." : "") (groupGuess ? " Passende Kategorie vorgeschlagen." : "")
); );
} }
@@ -327,7 +327,7 @@ export default function ProductForm() {
</div> </div>
</label> </label>
<label className="grow"> <label className="grow">
Gruppe Kategorie
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}> <select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
<option value=""> keine </option> <option value=""> keine </option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)} {groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}

View File

@@ -26,7 +26,7 @@ export default function ShoppingList() {
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Einkaufsliste</h1> <h1>Einkaufsliste</h1>
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div> <div className="sub">Produkte und Kategorien unter ihrem Mindestbestand</div>
</div> </div>
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -42,7 +42,7 @@ export default function ShoppingList() {
<li key={key} className={checked[key] ? "done" : ""}> <li key={key} className={checked[key] ? "done" : ""}>
<label> <label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} /> <input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span> <span className="badge accent">Kategorie</span>
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="muted small"> <span className="muted small">

View File

@@ -69,7 +69,7 @@ export default function Transfer() {
<p className="muted small mt-0"> <p className="muted small mt-0">
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder <strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
Einheiten, Gruppen und Lagerorten. Einheiten, Kategorien und Lagerorten.
</p> </p>
<div className="field-inline"> <div className="field-inline">
<button className="btn primary" onClick={() => download("csv")}> <button className="btn primary" onClick={() => download("csv")}>
@@ -84,7 +84,7 @@ export default function Transfer() {
<section className="card"> <section className="card">
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div> <div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
<p className="muted small mt-0"> <p className="muted small mt-0">
CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer CSV oder JSON auswählen. Unbekannte Produkte, Kategorien und Lagerorte werden immer
angelegt was mit vorhandenen Beständen passiert, bestimmt der Modus. angelegt was mit vorhandenen Beständen passiert, bestimmt der Modus.
</p> </p>
<form onSubmit={doImport}> <form onSubmit={doImport}>