iOS: Mindestbestand je Lagerort (Produkte) + Einkaufsliste je Ort
- Produkt-Detail: neuer Editor „Mindestbestand je Lagerort" (Zeilen: Lagerort + Menge in Artikeleinheiten), zusaetzlich zum Gesamt-Mindestbestand. - Einkaufsliste: Abschnitte „Bedarf: <Lagerort>" mit Produkten und Gruppen, die am Ort unter dem dort hinterlegten Mindestbestand liegen. - Models/Client: LocationMinStock(+In), LocationNeeds*, setProductLocationMinStock, shoppingByLocation; Product um location_min_stocks erweitert. (Gruppen-Bedarf je Ort ist vorerst nur im Web pflegbar, wird aber in der iOS- Einkaufsliste mit angezeigt.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
ios/AppIcon.icon/Assets/stock-rotation.svg
Normal file
1
ios/AppIcon.icon/Assets/stock-rotation.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" enable-background="new 0 0 500 500" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m394.394 334.235v-62.008l-53.677 31.007v62.009zm-120.521 0 53.733 31.007v-62.009l-53.733-31.007zm60.288-104.356 53.677 31.002-53.677 31.007-53.733-31.007zm73.344 31.001v77.136c0 2.342-1.233 4.51-3.306 5.681l-66.788 38.56c-2.049 1.189-4.531 1.175-6.555 0l-66.788-38.56c-2.017-1.171-3.306-3.339-3.306-5.681v-77.136c0-2.342 1.289-4.499 3.306-5.67l66.788-38.577c2.017-1.171 4.538-1.171 6.555 0l66.788 38.577c2.073 1.171 3.306 3.328 3.306 5.67zm-241.658-31.001-53.677 31.002 53.677 31.007 53.677-31.007zm60.233 104.356-53.677 31.007v-62.009l53.677-31.007zm-120.465 0v-62.008l53.677 31.007v62.009zm-13.111 3.782v-77.137c0-2.342 1.233-4.499 3.25-5.67l66.844-38.577c2.017-1.171 4.482-1.171 6.555 0l66.788 38.577c2.017 1.171 3.25 3.328 3.25 5.67v77.136c0 2.342-1.233 4.51-3.25 5.681l-66.788 38.56c-2.028 1.177-4.518 1.182-6.555 0l-66.844-38.56c-2.017-1.17-3.25-3.338-3.25-5.68zm157.5-253.245-53.677 30.996 53.677 31.007 53.677-31.007zm60.233 104.351v-62.003l-53.677 31.007v62.003zm-120.465 0 53.677 31.007v-62.003l-53.677-31.007zm-9.861 9.463 66.788 38.565c2.065 1.161 4.512 1.18 6.555 0l66.844-38.565c2.017-1.171 3.25-3.339 3.25-5.676v-77.142c0-2.331-1.233-4.499-3.25-5.67l-66.844-38.565c-2.017-1.171-4.482-1.171-6.555 0l-66.788 38.565c-2.017 1.171-3.25 3.339-3.25 5.67v77.142c0 2.337 1.233 4.505 3.25 5.676zm205.91 216.298-.28 2.264-14.344 42.695c-.952 2.734-3.474 4.466-6.219 4.466-4.443 0-7.617-4.413-6.219-8.64l7.9-23.572c-84.823 54.5-197.289 43.07-269.56-29.192-77.322-77.31-84.773-200.369-17.369-286.246 2.241-2.846 6.388-3.345 9.245-1.109 2.802 2.241 3.306 6.354 1.064 9.2-63.314 80.666-56.31 196.267 16.305 268.888 68.749 68.749 175.99 78.689 255.665 25.841l-28.183-3.636c-3.586-.459-6.107-3.748-5.659-7.329.448-3.592 3.754-6.135 7.34-5.664l44.6 5.737v.006c2.218.442 2.167.629 3.754 1.793l.504.538.112.123c.761 1.255 1.344 2.163 1.344 3.837zm34.402-31.539c-2.245 2.86-6.315 3.324-9.189 1.109-2.857-2.236-3.362-6.359-1.121-9.211 63.37-80.655 56.366-196.25-16.305-268.882-68.693-68.749-175.99-78.694-255.665-25.835l28.239 3.625c3.586.465 6.107 3.748 5.659 7.34-.47 3.604-3.822 6.133-7.34 5.659l-44.656-5.749c-1.283-.325-2.054-.403-3.306-1.429-.112-.106-.224-.23-.392-.342l-.56-.656c-.467-.093-1.457-2.622-1.457-4.102l.336-2.051 14.344-42.661c1.121-3.435 4.875-5.289 8.293-4.135s5.267 4.869 4.146 8.303l-7.9 23.561c84.549-54.349 197.113-43.238 269.56 29.197 77.323 77.318 84.775 200.382 17.314 286.259z" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -35,6 +35,31 @@
|
|||||||
"groups" : [
|
"groups" : [
|
||||||
{
|
{
|
||||||
"layers" : [
|
"layers" : [
|
||||||
|
{
|
||||||
|
"fill-specializations" : [
|
||||||
|
{
|
||||||
|
"value" : {
|
||||||
|
"solid" : "display-p3:0.00000,0.00000,0.00000,1.00000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "dark",
|
||||||
|
"value" : {
|
||||||
|
"solid" : "display-p3:0.58245,0.53181,0.88393,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass" : true,
|
||||||
|
"image-name" : "stock-rotation.svg",
|
||||||
|
"name" : "stock-rotation",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1.7,
|
||||||
|
"translation-in-points" : [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
@@ -51,6 +76,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"glass" : false,
|
"glass" : false,
|
||||||
|
"hidden" : true,
|
||||||
"image-name" : "barcode-gelesen.svg",
|
"image-name" : "barcode-gelesen.svg",
|
||||||
"name" : "barcode-gelesen",
|
"name" : "barcode-gelesen",
|
||||||
"position" : {
|
"position" : {
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ actor APIClient {
|
|||||||
return try await send(request, as: Product.self)
|
return try await send(request, as: Product.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
|
||||||
|
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
||||||
|
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
|
||||||
|
try jsonBody(&request, list)
|
||||||
|
return try await send(request, as: Product.self)
|
||||||
|
}
|
||||||
|
|
||||||
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
|
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
|
||||||
func productImage(id: Int) async throws -> Data? {
|
func productImage(id: Int) async throws -> Data? {
|
||||||
let request = try makeRequest("/products/\(id)/image")
|
let request = try makeRequest("/products/\(id)/image")
|
||||||
@@ -244,6 +251,10 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
|
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shoppingByLocation() async throws -> [LocationNeeds] {
|
||||||
|
try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].self)
|
||||||
|
}
|
||||||
|
|
||||||
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
|
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
|
||||||
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
|
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
|
||||||
return try await send(try makeRequest(path), as: [ExpiringItem].self)
|
return try await send(try makeRequest(path), as: [ExpiringItem].self)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ struct ShoppingListView: View {
|
|||||||
|
|
||||||
@State private var items: [ShoppingItem] = []
|
@State private var items: [ShoppingItem] = []
|
||||||
@State private var groups: [GroupShoppingItem] = []
|
@State private var groups: [GroupShoppingItem] = []
|
||||||
|
@State private var byLocation: [LocationNeeds] = []
|
||||||
@State private var busy = true
|
@State private var busy = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -80,7 +81,25 @@ struct ShoppingListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if items.isEmpty && groups.isEmpty && !busy {
|
ForEach(byLocation) { ort in
|
||||||
|
Section("Bedarf: \(ort.locationName)") {
|
||||||
|
ForEach(ort.products) { p in
|
||||||
|
ListRow(
|
||||||
|
systemImage: "shippingbox",
|
||||||
|
title: p.name,
|
||||||
|
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
|
||||||
|
) { EmptyView() }
|
||||||
|
}
|
||||||
|
ForEach(ort.groups) { g in
|
||||||
|
ListRow(
|
||||||
|
systemImage: "square.stack.3d.up",
|
||||||
|
title: g.name,
|
||||||
|
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
|
||||||
|
) { GroupBadge() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
|
||||||
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -97,6 +116,7 @@ struct ShoppingListView: View {
|
|||||||
do {
|
do {
|
||||||
items = try await APIClient.shared.shoppingList()
|
items = try await APIClient.shared.shoppingList()
|
||||||
groups = try await APIClient.shared.shoppingGroups()
|
groups = try await APIClient.shared.shoppingGroups()
|
||||||
|
byLocation = try await APIClient.shared.shoppingByLocation()
|
||||||
error = nil
|
error = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -76,9 +76,12 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
let fieldValues: [String: String?]?
|
let fieldValues: [String: String?]?
|
||||||
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
|
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
|
||||||
let individual: Bool?
|
let individual: Bool?
|
||||||
|
/// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand).
|
||||||
|
let locationMinStocks: [LocationMinStock]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual
|
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual
|
||||||
|
case locationMinStocks = "location_min_stocks"
|
||||||
case datePrecision = "date_precision"
|
case datePrecision = "date_precision"
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
case categoryName = "category_name"
|
case categoryName = "category_name"
|
||||||
@@ -129,6 +132,31 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
||||||
|
struct LocationMinStock: Codable, Hashable, Identifiable {
|
||||||
|
let locationId: Int
|
||||||
|
let locationName: String?
|
||||||
|
let minStock: Double
|
||||||
|
var id: Int { locationId }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case minStock = "min_stock"
|
||||||
|
case locationId = "location_id"
|
||||||
|
case locationName = "location_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
|
||||||
|
struct LocationMinStockIn: Codable {
|
||||||
|
let locationId: Int
|
||||||
|
let minStock: Double
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case minStock = "min_stock"
|
||||||
|
case locationId = "location_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Auswahleintrag fuer Einheiten.
|
/// Auswahleintrag fuer Einheiten.
|
||||||
///
|
///
|
||||||
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
|
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
|
||||||
@@ -324,6 +352,59 @@ struct GroupShoppingItem: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bedarfe je Lagerort
|
||||||
|
|
||||||
|
struct LocationNeedProduct: Codable, Identifiable {
|
||||||
|
let productId: Int
|
||||||
|
let name: String
|
||||||
|
let unitLabel: String
|
||||||
|
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 unitLabel = "unit_label"
|
||||||
|
case minStock = "min_stock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocationNeedGroup: Codable, Identifiable {
|
||||||
|
let groupId: Int
|
||||||
|
let name: String
|
||||||
|
let unitName: String
|
||||||
|
let stock: Double
|
||||||
|
let minStock: Double
|
||||||
|
let deficit: Double
|
||||||
|
|
||||||
|
var id: Int { groupId }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, stock, deficit
|
||||||
|
case groupId = "group_id"
|
||||||
|
case unitName = "unit_name"
|
||||||
|
case minStock = "min_stock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocationNeeds: Codable, Identifiable {
|
||||||
|
let locationId: Int
|
||||||
|
let locationName: String
|
||||||
|
let products: [LocationNeedProduct]
|
||||||
|
let groups: [LocationNeedGroup]
|
||||||
|
|
||||||
|
var id: Int { locationId }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case products, groups
|
||||||
|
case locationId = "location_id"
|
||||||
|
case locationName = "location_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ExpiringItem: Codable, Identifiable {
|
struct ExpiringItem: Codable, Identifiable {
|
||||||
let lotId: Int
|
let lotId: Int
|
||||||
let productId: Int
|
let productId: Int
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
ProductLocationMinView(product: current) { await reload() }
|
||||||
|
} label: {
|
||||||
|
let n = (current.locationMinStocks ?? []).count
|
||||||
|
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
|
||||||
|
systemImage: "mappin.and.ellipse")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
|
||||||
|
}
|
||||||
|
|
||||||
Section("Erkennung") {
|
Section("Erkennung") {
|
||||||
LabeledContent("Barcode", value: current.barcode ?? "–")
|
LabeledContent("Barcode", value: current.barcode ?? "–")
|
||||||
if !current.barcodes.isEmpty {
|
if !current.barcodes.isEmpty {
|
||||||
@@ -399,3 +411,98 @@ struct LotEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mindestbestand je Lagerort
|
||||||
|
|
||||||
|
/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen
|
||||||
|
/// Mindestbestand). Menge in Artikeleinheiten.
|
||||||
|
struct ProductLocationMinView: View {
|
||||||
|
let product: Product
|
||||||
|
var onChanged: (() async -> Void)? = nil
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private struct MinRow: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var locationId: Int?
|
||||||
|
var amount: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var rows: [MinRow] = []
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.articleUnitLabel).")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach($rows) { $row in
|
||||||
|
HStack {
|
||||||
|
Picker("Lagerort", selection: $row.locationId) {
|
||||||
|
Text("– wählen –").tag(Int?.none)
|
||||||
|
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
||||||
|
}
|
||||||
|
TextField("Menge", text: $row.amount)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 70)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
rows.removeAll { $0.id == row.id }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
rows.append(MinRow(locationId: nil, amount: ""))
|
||||||
|
} label: {
|
||||||
|
Label("Lagerort hinzufügen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Bedarf je Lagerort")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
rows = (product.locationMinStocks ?? []).map {
|
||||||
|
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
var list: [LocationMinStockIn] = []
|
||||||
|
var gesehen: Set<Int> = []
|
||||||
|
for row in rows {
|
||||||
|
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
|
||||||
|
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||||
|
if wert > 0 {
|
||||||
|
gesehen.insert(loc)
|
||||||
|
list.append(LocationMinStockIn(locationId: loc, minStock: wert))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
|
||||||
|
await onChanged?()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user