Die Uebersicht in der App war fest verdrahtet: vier Kacheln, unabhaengig davon, was am Rechner zusammengestellt wurde. Jetzt liest sie dieselben Dashboards wie die Web-Oberflaeche und stellt sie nativ dar. Das Raster laesst sich auf einem Telefon nicht sinnvoll nachbilden, deshalb stehen die Karten untereinander - in der Lesereihenfolge des Rasters, erst Zeile, dann Spalte. Zusammengestellt wird weiterhin am Rechner; gibt es mehrere Dashboards, schaltet oben ein Menue um. Neu ist die Buchungskarte: Artikel suchen, Menge, ein- oder auslagern, ohne den Scan-Ablauf zu oeffnen. Nach dem Buchen zieht die Karte den Bestand nach und laesst die uebrigen Karten neu laden - sonst stuende dort weiter die alte Zahl. Diagrammkarten bekommen bewusst keinen halbgaren Nachbau, sondern den ehrlichen Hinweis, dass es sie in der Web-Oberflaeche gibt. Geprueft mit der echten Antwort eines laufenden Servers: Sie decodiert in die Modelle der App, samt zweier Karten derselben Art mit verschiedenen Artikeln. OverviewView faellt weg, DashboardView tritt an ihre Stelle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
588 lines
16 KiB
Swift
588 lines
16 KiB
Swift
import Foundation
|
|
|
|
// Entspricht den Schemas des Backends (backend/app/schemas.py).
|
|
|
|
struct TokenResponse: Codable {
|
|
let accessToken: String
|
|
let role: String
|
|
let username: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accessToken = "access_token"
|
|
case role, username
|
|
}
|
|
}
|
|
|
|
struct MeResponse: Codable {
|
|
let id: Int
|
|
let username: String
|
|
let role: String
|
|
|
|
var isAdmin: Bool { role == "admin" }
|
|
}
|
|
|
|
struct Unit: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let kind: String
|
|
let factor: Double
|
|
/// Eingebaute Einheiten lassen sich umbenennen, aber nicht loeschen.
|
|
let isBuiltin: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, kind, factor
|
|
case isBuiltin = "is_builtin"
|
|
}
|
|
}
|
|
|
|
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let code: String
|
|
let note: String?
|
|
}
|
|
|
|
struct Product: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let groupId: Int?
|
|
let minStock: Double?
|
|
let stock: Double
|
|
let expiredCount: Int
|
|
let kind: String
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
let barcodes: [BarcodeEntry]
|
|
/// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month").
|
|
let datePrecision: String?
|
|
/// Kategorie: reine Ordnungshilfe fuer die Artikelliste (nicht die Gruppe).
|
|
let categoryId: Int?
|
|
let categoryName: String?
|
|
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
|
|
let minStockDisplay: Double?
|
|
let minStockUnitLabel: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, barcode, name, brand, stock, kind, barcodes
|
|
case datePrecision = "date_precision"
|
|
case categoryId = "category_id"
|
|
case categoryName = "category_name"
|
|
case minStockDisplay = "min_stock_display"
|
|
case minStockUnitLabel = "min_stock_unit_label"
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case groupId = "group_id"
|
|
case minStock = "min_stock"
|
|
case expiredCount = "expired_count"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
|
|
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
|
var articleUnitLabel: String {
|
|
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
|
|
return unitName
|
|
}
|
|
|
|
/// Faktor, um Basiseinheiten in Artikeleinheiten umzurechnen.
|
|
var articleUnitFactor: Double {
|
|
if let size = packageSize, size > 0 { return size }
|
|
return unitFactor == 0 ? 1 : unitFactor
|
|
}
|
|
|
|
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
|
|
|
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
|
|
enum StockLevel { case none, ok, close, below }
|
|
|
|
var stockLevel: StockLevel {
|
|
guard let minimum = minStock, minimum > 0 else { return .none }
|
|
if stock < minimum { return .below }
|
|
// Weniger als ein Viertel Luft: bald nachkaufen.
|
|
return stock < minimum * 1.25 ? .close : .ok
|
|
}
|
|
}
|
|
|
|
/// Auswahleintrag fuer Einheiten.
|
|
///
|
|
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
|
|
/// fuer das Gebinde nur das Schluesselwort "package" (siehe
|
|
/// services/conversion.py). Die Bezeichnung "Glas" oder "Dose" ist reine
|
|
/// Anzeige - wird sie mitgeschickt, antwortet der Server
|
|
/// "Unbekannte Einheit: Glas".
|
|
struct UnitOption: Hashable, Identifiable {
|
|
let value: String
|
|
let label: String
|
|
var id: String { value }
|
|
|
|
static let packageValue = "package"
|
|
}
|
|
|
|
struct LookupResult: Codable {
|
|
let found: Bool
|
|
let existingProduct: Product?
|
|
let suggestion: Suggestion?
|
|
/// Gruppe: nur gesetzt, wenn der Code dort ausdruecklich hinterlegt ist.
|
|
let groupId: Int?
|
|
let groupName: String?
|
|
/// Kategorie: aus der Open-Food-Facts-Einordnung vorgeschlagen.
|
|
let categoryId: Int?
|
|
let categoryName: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case found, suggestion
|
|
case existingProduct = "existing_product"
|
|
case groupId = "group_id"
|
|
case groupName = "group_name"
|
|
case categoryId = "category_id"
|
|
case categoryName = "category_name"
|
|
}
|
|
|
|
struct Suggestion: Codable {
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String?
|
|
let packageSize: Double?
|
|
let quantityText: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case barcode, name, brand
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case quantityText = "quantity_text"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Lot: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let productId: Int
|
|
let quantity: Double
|
|
let bestBefore: String?
|
|
let bestBeforePrecision: String?
|
|
let locationId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, quantity
|
|
case productId = "product_id"
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case locationId = "location_id"
|
|
}
|
|
}
|
|
|
|
struct StorageLocation: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
struct CheckInLine: Codable {
|
|
let quantity: Double
|
|
let bestBefore: String?
|
|
let bestBeforePrecision: String
|
|
let locationId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case locationId = "location_id"
|
|
}
|
|
}
|
|
|
|
struct BatchCheckInRequest: Codable {
|
|
let productId: Int
|
|
let unit: String
|
|
let lines: [CheckInLine]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case unit, lines
|
|
case productId = "product_id"
|
|
}
|
|
}
|
|
|
|
struct CheckOutRequest: Codable {
|
|
let productId: Int
|
|
let quantity: Double
|
|
let unit: String
|
|
let lotId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity, unit
|
|
case productId = "product_id"
|
|
case lotId = "lot_id"
|
|
}
|
|
}
|
|
|
|
struct StockResponse: Codable {
|
|
let productStock: Double
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case productStock = "product_stock"
|
|
}
|
|
}
|
|
|
|
struct NewProductRequest: Codable {
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let datePrecision: String
|
|
let groupId: Int?
|
|
let categoryId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case barcode, name, brand
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case datePrecision = "date_precision"
|
|
case groupId = "group_id"
|
|
case categoryId = "category_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Listen (Einkaufsliste, bald ablaufend)
|
|
|
|
struct ShoppingItem: Codable, Identifiable {
|
|
let productId: Int
|
|
let name: String
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let stock: Double
|
|
let minStock: Double
|
|
let deficit: Double
|
|
|
|
var id: Int { productId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, stock, deficit
|
|
case productId = "product_id"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case minStock = "min_stock"
|
|
}
|
|
}
|
|
|
|
struct GroupShoppingItem: Codable, Identifiable {
|
|
let groupId: Int
|
|
let name: String
|
|
let stock: Double
|
|
let minStock: Double
|
|
let deficit: Double
|
|
let unitName: String
|
|
let productCount: Int
|
|
|
|
var id: Int { groupId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, stock, deficit
|
|
case groupId = "group_id"
|
|
case minStock = "min_stock"
|
|
case unitName = "unit_name"
|
|
case productCount = "product_count"
|
|
}
|
|
}
|
|
|
|
struct ExpiringItem: Codable, Identifiable {
|
|
let lotId: Int
|
|
let productId: Int
|
|
let productName: String
|
|
let quantity: Double
|
|
let baseUnit: String
|
|
let bestBefore: String
|
|
let bestBeforePrecision: String?
|
|
let daysLeft: Int
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
|
|
var id: Int { lotId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case lotId = "lot_id"
|
|
case productId = "product_id"
|
|
case productName = "product_name"
|
|
case baseUnit = "base_unit"
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case daysLeft = "days_left"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
}
|
|
|
|
// MARK: - Aenderungen
|
|
|
|
/// Die Felder, die das Produktformular der App besitzt (PATCH).
|
|
///
|
|
/// 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 brand: String?
|
|
var packageSize: Double?
|
|
var packageLabel: String?
|
|
var datePrecision: String?
|
|
var groupId: Int?
|
|
var categoryId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, brand
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case datePrecision = "date_precision"
|
|
case groupId = "group_id"
|
|
case categoryId = "category_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)
|
|
try container.encode(categoryId, forKey: .categoryId)
|
|
}
|
|
}
|
|
|
|
struct LotUpdateRequest: Codable {
|
|
var quantity: Double?
|
|
var bestBefore: String?
|
|
var bestBeforePrecision: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
}
|
|
}
|
|
|
|
/// Ein Eintrag aus /settings (Schluessel/Wert).
|
|
struct SettingEntry: Codable {
|
|
let key: String
|
|
let value: String
|
|
}
|
|
|
|
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
|
|
struct GroupItem: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
}
|
|
|
|
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade).
|
|
struct CategoryItem: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Uebersicht und Verlauf
|
|
|
|
/// Kennzahlen der Startseite (GET /dashboard/stats).
|
|
struct DashboardStats: Codable {
|
|
let productsInStock: Int
|
|
let articleUnits: Double
|
|
let expiringSoon: Int
|
|
let expired: Int
|
|
let shoppingItems: Int
|
|
let productsTotal: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case productsInStock = "products_in_stock"
|
|
case articleUnits = "article_units"
|
|
case expiringSoon = "expiring_soon"
|
|
case expired
|
|
case shoppingItems = "shopping_items"
|
|
case productsTotal = "products_total"
|
|
}
|
|
}
|
|
|
|
/// Ein Eintrag im Bewegungsverlauf (GET /movements).
|
|
struct Movement: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let productId: Int
|
|
let productName: String
|
|
/// "in" oder "out"
|
|
let type: String
|
|
/// in Basiseinheiten
|
|
let quantity: Double
|
|
let baseUnit: String
|
|
let unitUsed: String
|
|
let username: String?
|
|
let note: String?
|
|
let createdAt: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, type, quantity, username, note
|
|
case productId = "product_id"
|
|
case productName = "product_name"
|
|
case baseUnit = "base_unit"
|
|
case unitUsed = "unit_used"
|
|
case createdAt = "created_at"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
|
|
var isIncoming: Bool { type == "in" }
|
|
}
|
|
|
|
// MARK: - Dashboards
|
|
|
|
/// Eine Karte auf einem Dashboard.
|
|
///
|
|
/// `i` ist die Kennung **dieser** Karte, `type` ihre Art - erst dadurch kann
|
|
/// dieselbe Art mehrfach auf einem Dashboard liegen. `props` traegt, was nur
|
|
/// diese eine Karte angeht (etwa welcher Artikel gemeint ist).
|
|
struct DashboardCard: Codable, Identifiable, Hashable {
|
|
let i: String
|
|
let type: String
|
|
let x: Int
|
|
let y: Int
|
|
let w: Int
|
|
let h: Int
|
|
let tage: Int?
|
|
let props: CardProps?
|
|
|
|
var id: String { i }
|
|
|
|
/// Nur die Felder, die die App auswertet. Die Rasterangaben kommen aus der
|
|
/// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge.
|
|
struct CardProps: Codable, Hashable {
|
|
let productId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case productId = "product_id"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Dashboard: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let position: Int
|
|
let layout: [DashboardCard]
|
|
}
|
|
|
|
struct DashboardList: Codable {
|
|
let dashboards: [Dashboard]
|
|
let source: String
|
|
let enforced: Bool
|
|
let hasDefault: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case dashboards, source, enforced
|
|
case hasDefault = "has_default"
|
|
}
|
|
}
|
|
|
|
// MARK: - Stammdaten
|
|
|
|
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
|
|
struct PackageType: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let singular: String
|
|
let plural: String
|
|
let isBuiltin: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, singular, plural
|
|
case isBuiltin = "is_builtin"
|
|
}
|
|
}
|
|
|
|
struct NewLocationRequest: Codable {
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
struct NewUnitRequest: Codable {
|
|
let name: String
|
|
let kind: String
|
|
let factor: Double
|
|
}
|
|
|
|
struct NewPackageTypeRequest: Codable {
|
|
let singular: String
|
|
let plural: String
|
|
}
|
|
|
|
struct NewCategoryRequest: Codable {
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
/// Umbenennen von Stammdaten. Ein einzelnes Feld reicht, weil das Backend nur
|
|
/// mitgeschickte Schluessel auswertet.
|
|
struct RenameRequest: Codable {
|
|
let name: String
|
|
}
|
|
|
|
struct PackageTypeUpdateRequest: Codable {
|
|
let singular: String
|
|
let plural: String
|
|
}
|
|
|
|
/// Neue Gruppe anlegen (nur fuer Administratoren).
|
|
struct NewGroupRequest: Codable {
|
|
let name: String
|
|
let minStock: Double?
|
|
let minStockUnitId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case minStock = "min_stock"
|
|
case minStockUnitId = "min_stock_unit_id"
|
|
}
|
|
}
|