iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten
iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
diesem Rechner ist kein Xcode vorhanden.
Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
fragt bei den zerstoerenden Modi nach.
Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
"vor 2 Wochen", dazu heute/morgen/gestern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
74
ios/README.md
Normal file
74
ios/README.md
Normal file
@@ -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
|
||||
127
ios/Sources/APIClient.swift
Normal file
127
ios/Sources/APIClient.swift
Normal file
@@ -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<T: Decodable>(_ 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<T: Encodable>(_ 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)
|
||||
}
|
||||
}
|
||||
149
ios/Sources/CheckInFormView.swift
Normal file
149
ios/Sources/CheckInFormView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
175
ios/Sources/CheckInView.swift
Normal file
175
ios/Sources/CheckInView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
210
ios/Sources/CheckOutView.swift
Normal file
210
ios/Sources/CheckOutView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ios/Sources/Info.plist
Normal file
59
ios/Sources/Info.plist
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Project-Good</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
|
||||
<!-- Kamera für den Barcode-Scan -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string>
|
||||
|
||||
<!-- Lokaler Server ohne HTTPS (Heimnetz) -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
||||
<key>UIApplicationShortcutItems</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>com.scarriffle.projectgood.checkin</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Einlagern</string>
|
||||
<key>UIApplicationShortcutItemIconType</key>
|
||||
<string>UIApplicationShortcutIconTypeAdd</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UIApplicationShortcutItemType</key>
|
||||
<string>com.scarriffle.projectgood.checkout</string>
|
||||
<key>UIApplicationShortcutItemTitle</key>
|
||||
<string>Auslagern</string>
|
||||
<key>UIApplicationShortcutItemIconType</key>
|
||||
<string>UIApplicationShortcutIconTypeRemove</string>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- projectgood://checkin bzw. projectgood://checkout -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.scarriffle.projectgood</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>projectgood</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
51
ios/Sources/LoginView.swift
Normal file
51
ios/Sources/LoginView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
204
ios/Sources/Models.swift
Normal file
204
ios/Sources/Models.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
136
ios/Sources/ProductViews.swift
Normal file
136
ios/Sources/ProductViews.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
69
ios/Sources/ProjectGoodApp.swift
Normal file
69
ios/Sources/ProjectGoodApp.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
93
ios/Sources/RootView.swift
Normal file
93
ios/Sources/RootView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
107
ios/Sources/ScannerView.swift
Normal file
107
ios/Sources/ScannerView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
95
ios/Sources/Session.swift
Normal file
95
ios/Sources/Session.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
43
ios/project.yml
Normal file
43
ios/project.yml
Normal file
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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() {
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
|
||||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th>Frist</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expiring.map((it) => (
|
||||
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
|
||||
<td>{it.product_name}</td>
|
||||
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
|
||||
<td>{it.best_before}</td>
|
||||
<td className="num">
|
||||
{it.days_left < 0
|
||||
? <span className="badge danger">{-it.days_left}d überf.</span>
|
||||
: it.days_left}
|
||||
{articlePrimary(it)}
|
||||
{articleSecondary(it) && (
|
||||
<div className="muted small">{articleSecondary(it)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{it.best_before}</td>
|
||||
<td>
|
||||
<span className={`badge ${it.days_left < 0 ? "danger" : "warn"}`}>
|
||||
{relativeExpiry(it.days_left)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -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() {
|
||||
</td>
|
||||
<td>{m.product_name}</td>
|
||||
<td className="num">
|
||||
{primaryAmount(m)}
|
||||
{secondaryAmount(m) && (
|
||||
<div className="muted small">{secondaryAmount(m)}</div>
|
||||
{articlePrimary(m)}
|
||||
{articleSecondary(m) && (
|
||||
<div className="muted small">{articleSecondary(m)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{m.username || "–"}</td>
|
||||
|
||||
@@ -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" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
|
||||
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
|
||||
{cls && (
|
||||
<span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
|
||||
style={{ marginLeft: 6 }}>
|
||||
{relativeExpiry(dLeft)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -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() {
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
||||
<p className="muted small mt-0">
|
||||
CSV oder JSON auswählen. Der Import <strong>ergänzt nur</strong>: 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.
|
||||
</p>
|
||||
<form onSubmit={doImport}>
|
||||
<label>
|
||||
@@ -78,6 +93,17 @@ export default function Transfer() {
|
||||
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
<label>
|
||||
Modus
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="add">Nur hinzufügen</option>
|
||||
<option value="replace_listed">Bestände der enthaltenen Produkte ersetzen</option>
|
||||
<option value="replace_all">Alle Bestände ersetzen</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className={`muted small mt-0 ${mode === "replace_all" ? "hint-danger" : ""}`}>
|
||||
{MODES[mode]}
|
||||
</p>
|
||||
<button className="btn primary" disabled={!file || busy}>
|
||||
{busy ? "Importiere…" : "Importieren"}
|
||||
</button>
|
||||
@@ -89,6 +115,7 @@ export default function Transfer() {
|
||||
<span>
|
||||
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` : ""}.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user