diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 1909318..a86582b 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -191,6 +191,35 @@ def _parse_date(value) -> date | None: raise ValueError(f"Datum nicht lesbar: {text}") +IMPORT_MODES = {"add", "replace_listed", "replace_all"} + + +def _clear_lots(db: Session, product: Product, user: User) -> None: + """Entfernt alle Chargen eines Produkts und protokolliert das als Korrektur.""" + lots = db.query(Lot).filter(Lot.product_id == product.id).all() + total = float(sum(lot.quantity for lot in lots)) + for lot in lots: + db.delete(lot) + if total > 0: + db.add( + Movement( + product_id=product.id, + lot_id=None, + user_id=user.id, + type=MovementType.adjust, + quantity=-total, + unit_used="base", + note="Import: Bestand ersetzt", + ) + ) + db.flush() + + +def _clear_all_lots(db: Session, user: User) -> None: + for product in db.query(Product).all(): + _clear_lots(db, product, user) + + def _get_or_create_location(db: Session, name: str | None) -> Location | None: name = (name or "").strip() if not name: @@ -268,7 +297,7 @@ def _quantity_to_base(db: Session, product: Product, amount: float, token: str) return amount * unit.factor -def _import_csv(db: Session, content: bytes, user: User) -> dict: +def _import_csv(db: Session, content: bytes, user: User, mode: str) -> dict: text = content.decode("utf-8-sig", errors="replace") sample = text[:2048] delimiter = ";" if sample.count(";") >= sample.count(",") else "," @@ -277,6 +306,10 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict: created_products: list[str] = [] lots_added = 0 errors: list[str] = [] + cleared: set[int] = set() + + if mode == "replace_all": + _clear_all_lots(db, user) for index, raw in enumerate(reader, start=2): # Zeile 1 = Kopfzeile row = {(k or "").strip().lower(): (v if v is not None else "") for k, v in raw.items()} @@ -284,6 +317,11 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict: continue try: product = _get_or_create_product(db, row, created_products) + # Beim Ersetzen: Bestand des Produkts einmalig leeren, bevor die + # Zeilen dieser Datei dazukommen. + if mode == "replace_listed" and product.id not in cleared: + _clear_lots(db, product, user) + cleared.add(product.id) amount = _num(row.get("menge")) if amount is None or amount <= 0: continue # Zeile ohne Bestand: nur Stammdaten @@ -314,18 +352,24 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict: return { "format": "csv", + "mode": mode, "products_created": len(created_products), + "products_cleared": len(cleared), "lots_added": lots_added, "errors": errors, } -def _import_json(db: Session, content: bytes, user: User) -> dict: +def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict: data = json.loads(content.decode("utf-8-sig", errors="replace")) created_products: list[str] = [] lots_added = 0 units_created = 0 errors: list[str] = [] + cleared: set[int] = set() + + if mode == "replace_all": + _clear_all_lots(db, user) for entry in data.get("units", []): try: @@ -372,6 +416,9 @@ def _import_json(db: Session, content: bytes, user: User) -> dict: "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "", } product = _get_or_create_product(db, row, created_products) + if mode == "replace_listed" and product.id not in cleared: + _clear_lots(db, product, user) + cleared.add(product.id) for lot_entry in entry.get("lots", []): quantity = float(lot_entry.get("quantity") or 0) if quantity <= 0: @@ -402,8 +449,10 @@ def _import_json(db: Session, content: bytes, user: User) -> dict: return { "format": "json", + "mode": mode, "units_created": units_created, "products_created": len(created_products), + "products_cleared": len(cleared), "lots_added": lots_added, "errors": errors, } @@ -411,11 +460,21 @@ def _import_json(db: Session, content: bytes, user: User) -> dict: @router.post("/import/stock") def import_stock( + mode: str = "add", file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_admin), ) -> dict: - """Importiert CSV oder JSON. Additiv – es wird nichts gelöscht.""" + """Importiert CSV oder JSON. + + mode: + add – nur ergänzen (Standard, löscht nie etwas) + replace_listed – Bestand der in der Datei genannten Produkte ersetzen + replace_all – alle Bestände vorher leeren (vollständige Wiederherstellung) + """ + if mode not in IMPORT_MODES: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannter Modus: {mode}") + content = file.file.read() if not content: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Datei ist leer") @@ -425,7 +484,11 @@ def import_stock( is_json = filename.endswith(".json") or stripped in (b"{", b"[") try: - result = _import_json(db, content, user) if is_json else _import_csv(db, content, user) + result = ( + _import_json(db, content, user, mode) + if is_json + else _import_csv(db, content, user, mode) + ) except json.JSONDecodeError as exc: db.rollback() raise HTTPException(status.HTTP_400_BAD_REQUEST, f"JSON nicht lesbar: {exc}") from exc diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index de79eae..1e83590 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -104,6 +104,7 @@ def expiring( result: list[ExpiringItem] = [] for lot in lots: product = lot.product + unit_name, unit_factor = display_unit_info(product) result.append( ExpiringItem( lot_id=lot.id, @@ -113,6 +114,10 @@ def expiring( base_unit=product.base_unit, best_before=lot.best_before, days_left=(lot.best_before - today).days, + package_size=product.package_size, + package_label=product.package_label, + unit_name=unit_name, + unit_factor=unit_factor, ) ) return result diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f88b4de..b0d7453 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -263,10 +263,15 @@ class ExpiringItem(BaseModel): lot_id: int product_id: int product_name: str - quantity: float + quantity: float # in Basiseinheiten base_unit: BaseUnit best_before: date days_left: int + # Für die Anzeige in Artikeleinheiten: + package_size: float | None = None + package_label: str | None = None + unit_name: str = "" + unit_factor: float = 1.0 class GroupShoppingItem(BaseModel): diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..93c432e --- /dev/null +++ b/ios/README.md @@ -0,0 +1,74 @@ +# Project-Good – iOS-App + +Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe +REST-API wie die Web-Oberfläche. + +> **Stand:** Erste lauffähige Fassung (Login, Scanner, Einlagern mit mehreren +> Chargen/MHDs, Auslagern mit Chargenauswahl, Artikel anlegen aus Open Food Facts). +> Sie wurde bisher **nicht in Xcode kompiliert** – auf dem Entwicklungsrechner ist +> kein macOS/Xcode vorhanden. Rechne beim ersten Build mit Kleinigkeiten. + +## Projekt in Xcode öffnen + +### Variante A – mit XcodeGen (empfohlen) +```bash +brew install xcodegen +cd ios +xcodegen generate +open ProjectGood.xcodeproj +``` +`project.yml` beschreibt das Projekt vollständig (Bundle-ID, Info.plist, +Shortcuts, URL-Schema). + +### Variante B – ohne XcodeGen +1. Xcode → *File ▸ New ▸ Project…* → **App**, Interface **SwiftUI**, Sprache **Swift** +2. Produktname `ProjectGood`, Bundle-ID z. B. `com.scarriffle.projectgood` +3. Die von Xcode erzeugte `ContentView.swift` und `…App.swift` löschen +4. Den Ordner `Sources/` per Drag & Drop ins Projekt ziehen („Copy items if needed") +5. In den Target-Einstellungen die mitgelieferte `Sources/Info.plist` als Info.plist setzen + (*Build Settings ▸ Info.plist File*) und *Generate Info.plist File* auf **No** stellen + +## Auf dem iPhone installieren +1. iPhone per Kabel verbinden, in Xcode als Ziel auswählen +2. *Signing & Capabilities* → dein Apple-Developer-Team wählen +3. ▶︎ Run. Beim ersten Start auf dem iPhone unter + *Einstellungen ▸ Allgemein ▸ VPN & Geräteverwaltung* dem Entwickler vertrauen + +## Erste Schritte in der App +Beim Start nach der Server-Adresse fragen lassen, z. B. `http://192.168.1.50:8080` +(dieselbe Adresse wie im Browser), dann mit deinem Benutzer anmelden. Adresse und +Token bleiben gespeichert – das Token liegt im Keychain. + +## Home-Screen-Shortcuts +Es gibt zwei Wege direkt in den Scan-Bildschirm: + +**Schnellaktionen** – langer Druck auf das App-Symbol zeigt „Einlagern" und +„Auslagern" (funktioniert ohne weitere Einrichtung). + +**Eigene Symbole auf dem Home-Bildschirm** – über die Apple-App *Kurzbefehle*: +1. Kurzbefehle ▸ **+** ▸ Aktion *URL öffnen* +2. URL `projectgood://checkin` (bzw. `projectgood://checkout`) eintragen +3. Kurzbefehl benennen, dann ▸ *Zum Home-Bildschirm hinzufügen* + +Beide Wege öffnen die App **direkt in der Kamera**. + +## Aufbau der Quellen +| Datei | Inhalt | +|---|---| +| `ProjectGoodApp.swift` | App-Einstieg, Schnellaktionen, URL-Schema | +| `Session.swift` | Server-Adresse (UserDefaults) und Token (Keychain) | +| `APIClient.swift` | REST-Aufrufe gegen `/api/...` | +| `Models.swift` | Codable-Typen passend zu `backend/app/schemas.py` | +| `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) | +| `RootView.swift` | Startbildschirm und Routing | +| `LoginView.swift` | Server + Anmeldung | +| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD | +| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO | +| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) | + +## 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 +- App-Icon diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift new file mode 100644 index 0000000..2ff9b65 --- /dev/null +++ b/ios/Sources/APIClient.swift @@ -0,0 +1,127 @@ +import Foundation + +enum APIError: LocalizedError { + case notConfigured + case unauthorized + case server(String) + + var errorDescription: String? { + switch self { + case .notConfigured: return "Server-Adresse fehlt. Bitte in den Einstellungen hinterlegen." + case .unauthorized: return "Nicht angemeldet oder Sitzung abgelaufen." + case .server(let message): return message + } + } +} + +/// Schlanker Client für die Project-Good-REST-API. +/// Alle Pfade laufen über /api (wie im Web, siehe web/nginx.conf). +actor APIClient { + static let shared = APIClient() + + private var baseURL: URL? { Session.shared.baseURL } + private var token: String? { Session.shared.token } + + // MARK: - Basis + + private func makeRequest(_ path: String, method: String = "GET") throws -> URLRequest { + guard let baseURL else { throw APIError.notConfigured } + guard let url = URL(string: "api" + path, relativeTo: baseURL) else { + throw APIError.notConfigured + } + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 15 + if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + return request + } + + private func send(_ request: URLRequest, as type: T.Type) async throws -> T { + let (data, response) = try await URLSession.shared.data(for: request) + try check(response, data: data) + return try JSONDecoder().decode(T.self, from: data) + } + + private func check(_ response: URLResponse, data: Data) throws { + guard let http = response as? HTTPURLResponse else { return } + guard !(200..<300).contains(http.statusCode) else { return } + if http.statusCode == 401 { throw APIError.unauthorized } + // FastAPI liefert Fehler als {"detail": "..."} + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let detail = object["detail"] as? String { + throw APIError.server(detail) + } + throw APIError.server("Serverfehler (\(http.statusCode))") + } + + private func jsonBody(_ request: inout URLRequest, _ value: T) throws { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(value) + } + + // MARK: - Auth + + func login(username: String, password: String) async throws -> TokenResponse { + var request = try makeRequest("/auth/login", method: "POST") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "username", value: username), + URLQueryItem(name: "password", value: password), + ] + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + return try await send(request, as: TokenResponse.self) + } + + func me() async throws -> MeResponse { + try await send(try makeRequest("/auth/me"), as: MeResponse.self) + } + + // MARK: - Stammdaten + + func units() async throws -> [Unit] { + try await send(try makeRequest("/units"), as: [Unit].self) + } + + func locations() async throws -> [StorageLocation] { + try await send(try makeRequest("/locations"), as: [StorageLocation].self) + } + + func searchProducts(_ query: String) async throws -> [Product] { + let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return try await send(try makeRequest("/products?q=\(escaped)"), as: [Product].self) + } + + func product(id: Int) async throws -> Product { + try await send(try makeRequest("/products/\(id)"), as: Product.self) + } + + func lookup(barcode: String) async throws -> LookupResult { + let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self) + } + + func createProduct(_ payload: NewProductRequest) async throws -> Product { + var request = try makeRequest("/products", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: Product.self) + } + + // MARK: - Bestand + + func lots(productId: Int) async throws -> [Lot] { + try await send(try makeRequest("/lots?product_id=\(productId)"), as: [Lot].self) + } + + func checkInBatch(_ payload: BatchCheckInRequest) async throws -> StockResponse { + var request = try makeRequest("/stock/checkin/batch", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: StockResponse.self) + } + + func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse { + var request = try makeRequest("/stock/checkout", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: StockResponse.self) + } +} diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift new file mode 100644 index 0000000..7ed8073 --- /dev/null +++ b/ios/Sources/CheckInFormView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD. +struct CheckInFormView: View { + let product: Product + let units: [Unit] + let locations: [StorageLocation] + var onDone: (String) -> Void + + @Environment(\.dismiss) private var dismiss + + struct Line: Identifiable { + let id = UUID() + var quantity: String = "1" + var hasDate: Bool = false + var bestBefore: Date = Date() + } + + @State private var lines: [Line] = [Line()] + @State private var unit: String = "" + @State private var locationId: Int? + @State private var busy = false + @State private var error: String? + + /// Einheiten der passenden Art plus das Gebinde des Artikels. + private var unitOptions: [String] { + var options = units.filter { $0.kind == product.kind }.map(\.name) + if let size = product.packageSize, size > 0 { + options.append(product.packageLabel ?? "Packung") + } + return options + } + + private var packageOptionName: String? { + guard let size = product.packageSize, size > 0 else { return nil } + return product.packageLabel ?? "Packung" + } + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading) { + Text(product.name).font(.headline) + Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + } + + Section("Einheit") { + Picker("Einheit", selection: $unit) { + ForEach(unitOptions, id: \.self) { Text($0).tag($0) } + } + if !locations.isEmpty { + Picker("Lagerort", selection: $locationId) { + Text("– keiner –").tag(Int?.none) + ForEach(locations) { location in + Text(location.name).tag(Int?.some(location.id)) + } + } + } + } + + Section("Chargen") { + ForEach($lines) { $line in + VStack(alignment: .leading, spacing: 6) { + HStack { + TextField("Menge", text: $line.quantity) + .keyboardType(.decimalPad) + 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) + if line.hasDate { + DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date) + } + } + .padding(.vertical, 2) + } + Button { + lines.append(Line()) + } label: { + Label("Weitere Charge (anderes MHD)", systemImage: "plus") + } + } + + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + + Section { + Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } } + .disabled(busy) + } + } + .navigationTitle("Einlagern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + .onAppear { + // Gibt es ein Gebinde, ist das die naheliegende Eingabe. + unit = packageOptionName ?? product.unitName + } + } + + private func format(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) + } + + private func submit() async { + error = nil + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + let payloadLines: [CheckInLine] = lines.compactMap { line in + guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")), + quantity > 0 else { return nil } + return CheckInLine( + quantity: quantity, + bestBefore: line.hasDate ? formatter.string(from: line.bestBefore) : nil, + locationId: locationId + ) + } + guard !payloadLines.isEmpty else { + error = "Bitte mindestens eine Menge angeben." + return + } + + busy = true + defer { busy = false } + do { + let response = try await APIClient.shared.checkInBatch( + BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines) + ) + let total = response.productStock / product.articleUnitFactor + onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)") + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift new file mode 100644 index 0000000..0e886ce --- /dev/null +++ b/ios/Sources/CheckInView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +struct CheckInView: View { + @Environment(\.dismiss) private var dismiss + + @State private var paused = false + @State private var status: String? + @State private var error: String? + + @State private var product: Product? + @State private var suggestion: LookupResult.Suggestion? + @State private var suggestedGroupId: Int? + @State private var unknownCode: String? + @State private var manualCodeShown = false + @State private var manualCode = "" + @State private var units: [Unit] = [] + @State private var locations: [StorageLocation] = [] + + var body: some View { + ZStack(alignment: .bottom) { + ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused) + .ignoresSafeArea() + + VStack(spacing: 10) { + if let status { banner(status, color: .green) } + if let error { banner(error, color: .red) } + + if let suggestion { + suggestionCard(suggestion) + } else if let unknownCode { + unknownCard(unknownCode) + } + + HStack(spacing: 10) { + Button { + paused = true + manualCodeShown = true + } label: { + Label("EAN manuell", systemImage: "keyboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + NavigationLink { + ProductFormView(prefillBarcode: nil, groupId: nil) { created in + product = created + } + } label: { + Label("Artikel anlegen", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + .padding(.bottom, 4) + .background(.ultraThinMaterial.opacity(0.001)) + } + .navigationTitle("Einlagern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Fertig") { dismiss() } + } + } + .task { + units = (try? await APIClient.shared.units()) ?? [] + locations = (try? await APIClient.shared.locations()) ?? [] + } + .alert("EAN eingeben", isPresented: $manualCodeShown) { + TextField("z.B. 8076809572569", text: $manualCode) + .keyboardType(.numberPad) + Button("Suchen") { + let code = manualCode + manualCode = "" + Task { await resolve(code) } + } + Button("Abbrechen", role: .cancel) { paused = false } + } + .sheet(item: $product) { item in + NavigationStack { + CheckInFormView(product: item, units: units, locations: locations) { message in + status = message + product = nil + paused = false + } + } + } + } + + // MARK: - Bausteine + + private func banner(_ text: String, color: Color) -> some View { + Text(text) + .font(.callout) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(0.9)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + private func suggestionCard(_ item: LookupResult.Suggestion) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(item.name).font(.headline) + Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) + .font(.caption).foregroundStyle(.secondary) + Text("Bei Open Food Facts gefunden, noch nicht im Katalog.") + .font(.caption2).foregroundStyle(.secondary) + NavigationLink { + ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, + suggestion: item) { created in + suggestion = nil + product = created + } + } label: { + Label("Anlegen & einlagern", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } + + private func unknownCard(_ code: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Unbekannter Code \(code)").font(.headline) + Text("Weder im Katalog noch bei Open Food Facts.") + .font(.caption).foregroundStyle(.secondary) + NavigationLink { + ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in + unknownCode = nil + product = created + } + } label: { + Label("Artikel anlegen", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .padding() + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } + + // MARK: - Logik + + private func resolve(_ code: String) async { + paused = true + error = nil + status = nil + suggestion = nil + unknownCode = nil + do { + let result = try await APIClient.shared.lookup(barcode: code) + suggestedGroupId = result.groupId + if let existing = result.existingProduct { + product = existing + } else if let hint = result.suggestion { + suggestion = hint + } else { + unknownCode = code + } + } catch { + self.error = error.localizedDescription + paused = false + } + } +} diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift new file mode 100644 index 0000000..5286cf9 --- /dev/null +++ b/ios/Sources/CheckOutView.swift @@ -0,0 +1,210 @@ +import SwiftUI + +struct CheckOutView: View { + @Environment(\.dismiss) private var dismiss + + @State private var paused = false + @State private var status: String? + @State private var error: String? + @State private var product: Product? + @State private var units: [Unit] = [] + @State private var manualCodeShown = false + @State private var manualCode = "" + @State private var searchShown = false + + var body: some View { + ZStack(alignment: .bottom) { + ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused) + .ignoresSafeArea() + + VStack(spacing: 10) { + if let status { banner(status, color: .green) } + if let error { banner(error, color: .red) } + + HStack(spacing: 10) { + Button { + paused = true + searchShown = true + } label: { + Label("Artikel suchen", systemImage: "magnifyingglass") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + Button { + paused = true + manualCodeShown = true + } label: { + Label("EAN manuell", systemImage: "keyboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.bottom, 8) + } + } + .navigationTitle("Auslagern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } + } + .task { units = (try? await APIClient.shared.units()) ?? [] } + .alert("EAN eingeben", isPresented: $manualCodeShown) { + TextField("z.B. 8076809572569", text: $manualCode) + .keyboardType(.numberPad) + Button("Suchen") { + let code = manualCode + manualCode = "" + Task { await resolve(code) } + } + Button("Abbrechen", role: .cancel) { paused = false } + } + .sheet(isPresented: $searchShown) { + NavigationStack { + ProductSearchView { picked in + searchShown = false + product = picked + } + } + } + .sheet(item: $product) { item in + NavigationStack { + CheckOutFormView(product: item, units: units) { message in + status = message + product = nil + paused = false + } + } + } + } + + private func banner(_ text: String, color: Color) -> some View { + Text(text) + .font(.callout) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(0.9)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + } + + private func resolve(_ code: String) async { + paused = true + error = nil + status = nil + do { + let result = try await APIClient.shared.lookup(barcode: code) + if let existing = result.existingProduct { + product = existing + } else { + error = "Kein bekanntes Produkt zu diesem Code im Lager." + paused = false + } + } catch { + self.error = error.localizedDescription + paused = false + } + } +} + +/// Auslagern: Menge, Einheit und optional eine bestimmte Charge (sonst FEFO). +struct CheckOutFormView: View { + let product: Product + let units: [Unit] + var onDone: (String) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var quantity = "1" + @State private var unit = "" + @State private var lots: [Lot] = [] + @State private var lotId: Int? + @State private var busy = false + @State private var error: String? + + private var unitOptions: [String] { + var options = units.filter { $0.kind == product.kind }.map(\.name) + if let size = product.packageSize, size > 0 { + options.append(product.packageLabel ?? "Packung") + } + return options + } + + var body: some View { + Form { + Section { + VStack(alignment: .leading) { + Text(product.name).font(.headline) + Text("Verfügbar: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + + Section("Charge") { + Picker("Charge", selection: $lotId) { + Text("Automatisch (zuerst ablaufende)").tag(Int?.none) + ForEach(lots) { lot in + Text(label(for: lot)).tag(Int?.some(lot.id)) + } + } + } + + Section("Menge") { + TextField("Menge", text: $quantity).keyboardType(.decimalPad) + Picker("Einheit", selection: $unit) { + ForEach(unitOptions, id: \.self) { Text($0).tag($0) } + } + } + + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + + Section { + Button(busy ? "Speichern…" : "Auslagern") { Task { await submit() } } + .disabled(busy) + } + } + .navigationTitle("Auslagern") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + .task { + unit = (product.packageSize ?? 0) > 0 ? (product.packageLabel ?? "Packung") : product.unitName + lots = (try? await APIClient.shared.lots(productId: product.id)) ?? [] + } + } + + private func label(for lot: Lot) -> String { + let amount = format(lot.quantity / product.articleUnitFactor) + let date = lot.bestBefore ?? "ohne MHD" + return "\(date) · \(amount) \(product.articleUnitLabel)" + } + + private func format(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) + } + + private func submit() async { + error = nil + guard let amount = Double(quantity.replacingOccurrences(of: ",", with: ".")), amount > 0 else { + error = "Bitte eine Menge größer 0 angeben." + return + } + busy = true + defer { busy = false } + do { + let response = try await APIClient.shared.checkOut( + CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId) + ) + let total = response.productStock / product.articleUnitFactor + onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)") + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/Info.plist b/ios/Sources/Info.plist new file mode 100644 index 0000000..fe74b7d --- /dev/null +++ b/ios/Sources/Info.plist @@ -0,0 +1,59 @@ + + + + + CFBundleDisplayName + Project-Good + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + + + NSCameraUsageDescription + Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet. + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + + UIApplicationShortcutItems + + + UIApplicationShortcutItemType + com.scarriffle.projectgood.checkin + UIApplicationShortcutItemTitle + Einlagern + UIApplicationShortcutItemIconType + UIApplicationShortcutIconTypeAdd + + + UIApplicationShortcutItemType + com.scarriffle.projectgood.checkout + UIApplicationShortcutItemTitle + Auslagern + UIApplicationShortcutItemIconType + UIApplicationShortcutIconTypeRemove + + + + + CFBundleURLTypes + + + CFBundleURLName + com.scarriffle.projectgood + CFBundleURLSchemes + + projectgood + + + + + diff --git a/ios/Sources/LoginView.swift b/ios/Sources/LoginView.swift new file mode 100644 index 0000000..849aa49 --- /dev/null +++ b/ios/Sources/LoginView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct LoginView: View { + @EnvironmentObject private var session: Session + + @State private var server: String = Session.shared.baseURL?.absoluteString ?? "" + @State private var username = "" + @State private var password = "" + @State private var error: String? + @State private var busy = false + + var body: some View { + NavigationStack { + Form { + Section("Server") { + TextField("http://192.168.1.10:8080", text: $server) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + Section("Anmeldung") { + TextField("Benutzername", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Passwort", text: $password) + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + Section { + Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } } + .disabled(busy || server.isEmpty || username.isEmpty) + } + } + .navigationTitle("Project-Good") + } + } + + private func login() async { + error = nil + busy = true + defer { busy = false } + session.setServer(server) + do { + let token = try await APIClient.shared.login(username: username, password: password) + session.store(token: token.accessToken, username: token.username, isAdmin: token.role == "admin") + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift new file mode 100644 index 0000000..2bca744 --- /dev/null +++ b/ios/Sources/Models.swift @@ -0,0 +1,204 @@ +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 +} + +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] + + enum CodingKeys: String, CodingKey { + case id, barcode, name, brand, stock, kind, barcodes + 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 } +} + +struct LookupResult: Codable { + let found: Bool + let existingProduct: Product? + let suggestion: Suggestion? + let groupId: Int? + let groupName: String? + + enum CodingKeys: String, CodingKey { + case found, suggestion + case existingProduct = "existing_product" + case groupId = "group_id" + case groupName = "group_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 locationId: Int? + + enum CodingKeys: String, CodingKey { + case id, quantity + case productId = "product_id" + case bestBefore = "best_before" + 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 locationId: Int? + + enum CodingKeys: String, CodingKey { + case quantity + case bestBefore = "best_before" + 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 groupId: 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 groupId = "group_id" + } +} diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift new file mode 100644 index 0000000..005cc72 --- /dev/null +++ b/ios/Sources/ProductViews.swift @@ -0,0 +1,136 @@ +import SwiftUI + +/// Artikel per Namenssuche auswählen (Alternative zum Scannen). +struct ProductSearchView: View { + var onPick: (Product) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @State private var results: [Product] = [] + @State private var busy = false + + var body: some View { + List { + ForEach(results) { product in + Button { + onPick(product) + dismiss() + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(product.name) + Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + } + if results.isEmpty && !busy { + Text("Keine Treffer").foregroundStyle(.secondary) + } + } + .searchable(text: $query, prompt: "Artikel suchen") + .onSubmit(of: .search) { Task { await search() } } + .task { await search() } + .onChange(of: query) { _, _ in Task { await search() } } + .navigationTitle("Artikel suchen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) + } + + private func search() async { + busy = true + defer { busy = false } + results = (try? await APIClient.shared.searchProducts(query)) ?? [] + } +} + +/// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt. +struct ProductFormView: View { + let prefillBarcode: String? + let groupId: Int? + var suggestion: LookupResult.Suggestion? + var onCreated: (Product) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var barcode = "" + @State private var name = "" + @State private var brand = "" + @State private var baseUnit = "piece" + @State private var packageSize = "" + @State private var packageLabel = "" + @State private var busy = false + @State private var error: String? + + private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")] + private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] + + var body: some View { + Form { + Section("Artikel") { + TextField("Barcode", text: $barcode).keyboardType(.numberPad) + TextField("Name", text: $name) + TextField("Marke", text: $brand) + } + Section("Einheit") { + Picker("Basiseinheit", selection: $baseUnit) { + ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } + } + TextField("Packungsgröße (in Basiseinheit)", text: $packageSize) + .keyboardType(.decimalPad) + Picker("Bezeichnung", selection: $packageLabel) { + ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } + } + } + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + Section { + Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } } + .disabled(busy || name.isEmpty) + } + } + .navigationTitle("Neuer Artikel") + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: prefill) + } + + private func prefill() { + barcode = suggestion?.barcode ?? prefillBarcode ?? "" + if let suggestion { + name = suggestion.name + brand = suggestion.brand ?? "" + baseUnit = suggestion.baseUnit ?? "piece" + if let size = suggestion.packageSize { packageSize = String(size) } + } + } + + private func create() async { + error = nil + busy = true + defer { busy = false } + do { + let product = try await APIClient.shared.createProduct( + NewProductRequest( + barcode: barcode.isEmpty ? nil : barcode, + name: name, + brand: brand.isEmpty ? nil : brand, + imageUrl: suggestion?.imageUrl, + baseUnit: baseUnit, + packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), + packageLabel: packageLabel.isEmpty ? nil : packageLabel, + groupId: groupId + ) + ) + onCreated(product) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/ProjectGoodApp.swift b/ios/Sources/ProjectGoodApp.swift new file mode 100644 index 0000000..3d2c2c1 --- /dev/null +++ b/ios/Sources/ProjectGoodApp.swift @@ -0,0 +1,69 @@ +import SwiftUI +import UIKit + +/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird. +enum Route: String { + case checkin + case checkout +} + +final class Router: ObservableObject { + @Published var route: Route? + + func handle(shortcut type: String) { + if type.hasSuffix("checkin") { route = .checkin } + if type.hasSuffix("checkout") { route = .checkout } + } + + /// projectgood://checkin bzw. projectgood://checkout + func handle(url: URL) { + guard url.scheme == "projectgood" else { return } + let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased() + if let route = Route(rawValue: target) { self.route = route } + } +} + +@main +struct ProjectGoodApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var session = Session.shared + @StateObject private var router = Router() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(session) + .environmentObject(router) + .onOpenURL { router.handle(url: $0) } + .onAppear { + if let type = AppDelegate.pendingShortcut { + router.handle(shortcut: type) + AppDelegate.pendingShortcut = nil + } + } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in + if let type = note.object as? String { router.handle(shortcut: type) } + } + } + } +} + +/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart). +final class AppDelegate: NSObject, UIApplicationDelegate { + static let shortcutNotification = Notification.Name("ProjectGoodShortcut") + static var pendingShortcut: String? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem { + AppDelegate.pendingShortcut = item.type + } + return true + } + + func application(_ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem) async -> Bool { + NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type) + return true + } +} diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift new file mode 100644 index 0000000..d3f52f6 --- /dev/null +++ b/ios/Sources/RootView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject private var session: Session + @EnvironmentObject private var router: Router + + var body: some View { + Group { + if session.isLoggedIn { + HomeView() + } else { + LoginView() + } + } + // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. + .fullScreenCover(item: $router.route) { route in + NavigationStack { + switch route { + case .checkin: CheckInView() + case .checkout: CheckOutView() + } + } + } + } +} + +extension Route: Identifiable { + var id: String { rawValue } +} + +struct HomeView: View { + @EnvironmentObject private var session: Session + @EnvironmentObject private var router: Router + + var body: some View { + NavigationStack { + VStack(spacing: 16) { + Button { + router.route = .checkin + } label: { + ActionTile(title: "Einlagern", subtitle: "Barcode scannen und Bestand erfassen", + systemImage: "arrow.down.to.line") + } + Button { + router.route = .checkout + } label: { + ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen", + systemImage: "arrow.up.from.line") + } + Spacer() + } + .padding() + .navigationTitle("Project-Good") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Text("Angemeldet als \(session.username)") + Button("Abmelden", role: .destructive) { session.logout() } + } label: { + Image(systemName: "person.circle") + } + } + } + } + } +} + +struct ActionTile: View { + let title: String + let subtitle: String + let systemImage: String + + var body: some View { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.title2) + .frame(width: 44, height: 44) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.headline) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(.tertiary) + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.primary) + } +} diff --git a/ios/Sources/ScannerView.swift b/ios/Sources/ScannerView.swift new file mode 100644 index 0000000..120f293 --- /dev/null +++ b/ios/Sources/ScannerView.swift @@ -0,0 +1,107 @@ +import AVFoundation +import SwiftUI + +/// Live-Kamerabild mit Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR). +/// Meldet jeden erkannten Code einmal – erst nach `resume()` wieder. +struct ScannerView: UIViewControllerRepresentable { + var onCode: (String) -> Void + @Binding var isPaused: Bool + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + func makeUIViewController(context: Context) -> ScannerViewController { + let controller = ScannerViewController() + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ controller: ScannerViewController, context: Context) { + context.coordinator.isPaused = isPaused + } + + final class Coordinator: NSObject, ScannerViewControllerDelegate { + let onCode: (String) -> Void + var isPaused: Bool = false + private var lastCode: String? + private var lastTime: Date = .distantPast + + init(onCode: @escaping (String) -> Void) { self.onCode = onCode } + + func scanner(_ controller: ScannerViewController, didFind code: String) { + guard !isPaused else { return } + // Entprellen: derselbe Code nicht mehrfach in kurzer Folge. + if code == lastCode, Date().timeIntervalSince(lastTime) < 2 { return } + lastCode = code + lastTime = Date() + AudioServicesPlaySystemSound(1057) + onCode(code) + } + } +} + +protocol ScannerViewControllerDelegate: AnyObject { + func scanner(_ controller: ScannerViewController, didFind code: String) +} + +final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + weak var delegate: ScannerViewControllerDelegate? + + private let session = AVCaptureSession() + private var preview: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + DispatchQueue.main.async { self?.configure() } + } + } + + private func configure() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.ean13, .ean8, .upce, .code128, .code39, .qr] + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.addSublayer(layer) + preview = layer + + start() + } + + func start() { + guard !session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() } + } + + func stop() { + guard session.isRunning else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() } + } + + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); start() } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); stop() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + preview?.frame = view.bounds + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection) { + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = object.stringValue else { return } + delegate?.scanner(self, didFind: value) + } +} diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift new file mode 100644 index 0000000..3f03cbe --- /dev/null +++ b/ios/Sources/Session.swift @@ -0,0 +1,95 @@ +import Foundation +import Security + +/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain, +/// die Server-URL in den UserDefaults (nicht geheim). +final class Session: ObservableObject { + static let shared = Session() + + private let urlKey = "server_url" + private let keychainAccount = "project-good-token" + + @Published private(set) var baseURL: URL? + @Published private(set) var token: String? + @Published var username: String = "" + @Published var isAdmin: Bool = false + + var isLoggedIn: Bool { token != nil && baseURL != nil } + + private init() { + if let stored = UserDefaults.standard.string(forKey: urlKey) { + baseURL = Session.normalize(stored) + } + token = Keychain.read(account: keychainAccount) + } + + /// Sorgt für eine URL mit Schema und abschließendem "/", damit relative + /// Pfade ("api/...") korrekt aufgelöst werden. + static func normalize(_ raw: String) -> URL? { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if !text.contains("://") { text = "http://" + text } + if !text.hasSuffix("/") { text += "/" } + return URL(string: text) + } + + func setServer(_ raw: String) { + guard let url = Session.normalize(raw) else { return } + baseURL = url + UserDefaults.standard.set(url.absoluteString, forKey: urlKey) + } + + func store(token newToken: String, username name: String, isAdmin admin: Bool) { + token = newToken + username = name + isAdmin = admin + Keychain.write(newToken, account: keychainAccount) + } + + func logout() { + token = nil + username = "" + isAdmin = false + Keychain.delete(account: keychainAccount) + } +} + +/// Minimaler Keychain-Zugriff für ein einzelnes Token. +enum Keychain { + private static let service = "com.scarriffle.projectgood" + + static func write(_ value: String, account: String) { + delete(account: account) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(value.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + SecItemAdd(query as CFDictionary, nil) + } + + static func read(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..38cce41 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,43 @@ +# XcodeGen-Spezifikation – erzeugt das Xcode-Projekt aus diesen Quellen. +# brew install xcodegen && cd ios && xcodegen generate && open ProjectGood.xcodeproj +# Alternativ kann man in Xcode ein leeres App-Projekt anlegen und den Ordner +# "Sources" hineinziehen (siehe README.md). +name: ProjectGood +options: + bundleIdPrefix: com.scarriffle + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true + +targets: + ProjectGood: + type: application + platform: iOS + sources: + - path: Sources + info: + path: Sources/Info.plist + properties: + CFBundleDisplayName: Project-Good + CFBundleShortVersionString: "1.0" + CFBundleVersion: "1" + UILaunchScreen: {} + NSCameraUsageDescription: >- + Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet. + UIApplicationShortcutItems: + - UIApplicationShortcutItemType: com.scarriffle.projectgood.checkin + UIApplicationShortcutItemTitle: Einlagern + UIApplicationShortcutItemIconType: UIApplicationShortcutIconTypeAdd + - UIApplicationShortcutItemType: com.scarriffle.projectgood.checkout + UIApplicationShortcutItemTitle: Auslagern + UIApplicationShortcutItemIconType: UIApplicationShortcutIconTypeRemove + CFBundleURLTypes: + - CFBundleURLName: com.scarriffle.projectgood + CFBundleURLSchemes: [projectgood] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.projectgood + MARKETING_VERSION: "1.0" + TARGETED_DEVICE_FAMILY: "1,2" + SWIFT_VERSION: "5.9" + GENERATE_INFOPLIST_FILE: NO diff --git a/web/src/api.js b/web/src/api.js index 04932f6..4367268 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -139,10 +139,13 @@ export const api = { // Export / Import exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"), exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"), - importStock: (file) => { + importStock: (file, mode = "add") => { const fd = new FormData(); fd.append("file", file); - return request("/import/stock", { method: "POST", formData: fd }); + return request(`/import/stock?mode=${encodeURIComponent(mode)}`, { + method: "POST", + formData: fd, + }); }, listUnits: () => request("/units"), diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index fd092e1..7c9c533 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { api } from "../api"; import Icon from "../components/Icon"; -import { amountText, fmt, unitShort } from "../units"; +import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units"; export default function Dashboard() { const [expiring, setExpiring] = useState([]); @@ -77,18 +77,23 @@ export default function Dashboard() {
- + {expiring.map((it) => ( - - + + ))} diff --git a/web/src/pages/History.jsx b/web/src/pages/History.jsx index 25465c4..fbea087 100644 --- a/web/src/pages/History.jsx +++ b/web/src/pages/History.jsx @@ -1,26 +1,10 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; -import { fmt, unitShort } from "../units"; +import { articlePrimary, articleSecondary } from "../units"; const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" }; -// Leitangabe in Artikeleinheiten (Gebinde), sonst in der Produkteinheit. -function primaryAmount(m) { - if (m.package_size > 0) { - return `${fmt(m.quantity / m.package_size)} ${m.package_label || "Packung"}`; - } - return `${fmt(m.quantity / (m.unit_factor || 1))} ${m.unit_name || unitShort(m.base_unit)}`; -} - -// Untermenge in der Basiseinheit – nur wenn sie sich von der Leitangabe unterscheidet. -function secondaryAmount(m) { - if (m.package_size > 0 || (m.unit_factor || 1) !== 1) { - return `${fmt(m.quantity)} ${unitShort(m.base_unit)}`; - } - return ""; -} - export default function History() { const [movements, setMovements] = useState([]); const [error, setError] = useState(null); @@ -63,9 +47,9 @@ export default function History() { diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index d5b2e22..7d038b1 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -5,7 +5,7 @@ import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; import { guessGroup } from "../offUtils"; -import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units"; +import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units"; const EMPTY = { barcode: "", name: "", brand: "", image_url: "", @@ -502,8 +502,12 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha ) : ( <> {l.best_before || "–"} - {cls === "row-danger" && abgelaufen} - {cls === "row-warn" && {dLeft}d} + {cls && ( + + {relativeExpiry(dLeft)} + + )} )} diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 4c6230d..5b37eb4 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -5,10 +5,19 @@ import Icon from "../components/Icon"; export default function Transfer() { const fileRef = useRef(null); const [file, setFile] = useState(null); + const [mode, setMode] = useState("add"); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const MODES = { + add: "Nur hinzufügen – es wird nichts gelöscht.", + replace_listed: + "Bestand der Produkte ersetzen, die in der Datei vorkommen. Ideal für eine Inventur: exportieren, korrigieren, zurückspielen.", + replace_all: + "Alle Bestände vorher leeren und komplett neu aufbauen. Für die vollständige Wiederherstellung aus einem Backup.", + }; + async function download(kind) { setError(null); try { @@ -22,11 +31,18 @@ export default function Transfer() { async function doImport(e) { e.preventDefault(); if (!file) return; + if (mode !== "add") { + const warning = + mode === "replace_all" + ? "ALLE Bestände werden vorher gelöscht und aus der Datei neu aufgebaut. Fortfahren?" + : "Für alle Produkte in der Datei werden die vorhandenen Chargen gelöscht und ersetzt. Fortfahren?"; + if (!confirm(warning)) return; + } setError(null); setResult(null); setBusy(true); try { - setResult(await api.importStock(file)); + setResult(await api.importStock(file, mode)); setFile(null); if (fileRef.current) fileRef.current.value = ""; } catch (err) { @@ -68,9 +84,8 @@ export default function Transfer() {

Import

- CSV oder JSON auswählen. Der Import ergänzt nur: unbekannte Produkte, - Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht - oder überschrieben. + CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer + angelegt – was mit vorhandenen Beständen passiert, bestimmt der Modus.

+ +

+ {MODES[mode]} +

@@ -89,6 +115,7 @@ export default function Transfer() { Import abgeschlossen ({result.format?.toUpperCase()}):{" "} {result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt + {result.products_cleared ? `, ${result.products_cleared} Produkt(e) vorher geleert` : ""} {result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}. diff --git a/web/src/units.js b/web/src/units.js index 525978a..a3cbce8 100644 --- a/web/src/units.js +++ b/web/src/units.js @@ -53,6 +53,30 @@ export function isExpired(dateStr) { return d != null && d < 0; } +// 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. +export function relativeExpiry(daysLeft) { + if (daysLeft == null) return ""; + if (daysLeft === 0) return "heute"; + if (daysLeft === 1) return "morgen"; + if (daysLeft === -1) return "gestern"; + + const abs = Math.abs(daysLeft); + let value; + let unit; + if (abs < 7) { + value = abs; + unit = value === 1 ? "Tag" : "Tagen"; + } else if (abs < 60) { + value = Math.round(abs / 7); + unit = value === 1 ? "Woche" : "Wochen"; + } else { + value = Math.round(abs / 30); + unit = value === 1 ? "Monat" : "Monaten"; + } + return daysLeft > 0 ? `in ${value} ${unit}` : `vor ${value} ${unit}`; +} + // CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist. export function expiryRowClass(dateStr, warnDays = 7) { const d = daysUntil(dateStr); @@ -85,6 +109,23 @@ export function buildUnitOptions(product, units) { return opts; } +// Leitangabe einer Menge in Artikeleinheiten (Gebinde), sonst in der Produkteinheit. +// Erwartet ein Objekt mit quantity, package_size, package_label, unit_name, unit_factor, base_unit. +export function articlePrimary(item) { + if (item.package_size > 0) { + return `${fmt(item.quantity / item.package_size)} ${item.package_label || "Packung"}`; + } + return `${fmt(item.quantity / (item.unit_factor || 1))} ${item.unit_name || unitShort(item.base_unit)}`; +} + +// Untermenge in der Basiseinheit – nur wenn sie sich von der Leitangabe unterscheidet. +export function articleSecondary(item) { + if (item.package_size > 0 || (item.unit_factor || 1) !== 1) { + return `${fmt(item.quantity)} ${unitShort(item.base_unit)}`; + } + return ""; +} + // Bestand eines Produkts in seiner Anzeigeeinheit, z.B. "1,5 Kilogramm". export function stockLabel(product) { const factor = product.unit_factor || 1;
ProduktMengeMHDTage
ProduktMengeMHDFrist
{it.product_name}{fmt(it.quantity)} {unitShort(it.base_unit)}{it.best_before} - {it.days_left < 0 - ? {-it.days_left}d überf. - : it.days_left} + {articlePrimary(it)} + {articleSecondary(it) && ( +
{articleSecondary(it)}
+ )} +
{it.best_before} + + {relativeExpiry(it.days_left)} +
{m.product_name} - {primaryAmount(m)} - {secondaryAmount(m) && ( -
{secondaryAmount(m)}
+ {articlePrimary(m)} + {articleSecondary(m) && ( +
{articleSecondary(m)}
)}
{m.username || "–"}