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:
Scarriffle
2026-07-22 14:59:04 +02:00
parent de6c8632a1
commit 4bb9a10366
23 changed files with 1770 additions and 41 deletions

View File

@@ -191,6 +191,35 @@ def _parse_date(value) -> date | None:
raise ValueError(f"Datum nicht lesbar: {text}") 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: def _get_or_create_location(db: Session, name: str | None) -> Location | None:
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
@@ -268,7 +297,7 @@ def _quantity_to_base(db: Session, product: Product, amount: float, token: str)
return amount * unit.factor 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") text = content.decode("utf-8-sig", errors="replace")
sample = text[:2048] sample = text[:2048]
delimiter = ";" if sample.count(";") >= sample.count(",") else "," 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] = [] created_products: list[str] = []
lots_added = 0 lots_added = 0
errors: list[str] = [] 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 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()} 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 continue
try: try:
product = _get_or_create_product(db, row, created_products) 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")) amount = _num(row.get("menge"))
if amount is None or amount <= 0: if amount is None or amount <= 0:
continue # Zeile ohne Bestand: nur Stammdaten continue # Zeile ohne Bestand: nur Stammdaten
@@ -314,18 +352,24 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict:
return { return {
"format": "csv", "format": "csv",
"mode": mode,
"products_created": len(created_products), "products_created": len(created_products),
"products_cleared": len(cleared),
"lots_added": lots_added, "lots_added": lots_added,
"errors": errors, "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")) data = json.loads(content.decode("utf-8-sig", errors="replace"))
created_products: list[str] = [] created_products: list[str] = []
lots_added = 0 lots_added = 0
units_created = 0 units_created = 0
errors: list[str] = [] errors: list[str] = []
cleared: set[int] = set()
if mode == "replace_all":
_clear_all_lots(db, user)
for entry in data.get("units", []): for entry in data.get("units", []):
try: 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 "", "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
} }
product = _get_or_create_product(db, row, created_products) 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", []): for lot_entry in entry.get("lots", []):
quantity = float(lot_entry.get("quantity") or 0) quantity = float(lot_entry.get("quantity") or 0)
if quantity <= 0: if quantity <= 0:
@@ -402,8 +449,10 @@ def _import_json(db: Session, content: bytes, user: User) -> dict:
return { return {
"format": "json", "format": "json",
"mode": mode,
"units_created": units_created, "units_created": units_created,
"products_created": len(created_products), "products_created": len(created_products),
"products_cleared": len(cleared),
"lots_added": lots_added, "lots_added": lots_added,
"errors": errors, "errors": errors,
} }
@@ -411,11 +460,21 @@ def _import_json(db: Session, content: bytes, user: User) -> dict:
@router.post("/import/stock") @router.post("/import/stock")
def import_stock( def import_stock(
mode: str = "add",
file: UploadFile = File(...), file: UploadFile = File(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> dict: ) -> 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() content = file.file.read()
if not content: if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Datei ist leer") 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"[") is_json = filename.endswith(".json") or stripped in (b"{", b"[")
try: 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: except json.JSONDecodeError as exc:
db.rollback() db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"JSON nicht lesbar: {exc}") from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, f"JSON nicht lesbar: {exc}") from exc

View File

@@ -104,6 +104,7 @@ def expiring(
result: list[ExpiringItem] = [] result: list[ExpiringItem] = []
for lot in lots: for lot in lots:
product = lot.product product = lot.product
unit_name, unit_factor = display_unit_info(product)
result.append( result.append(
ExpiringItem( ExpiringItem(
lot_id=lot.id, lot_id=lot.id,
@@ -113,6 +114,10 @@ def expiring(
base_unit=product.base_unit, base_unit=product.base_unit,
best_before=lot.best_before, best_before=lot.best_before,
days_left=(lot.best_before - today).days, 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 return result

View File

@@ -263,10 +263,15 @@ class ExpiringItem(BaseModel):
lot_id: int lot_id: int
product_id: int product_id: int
product_name: str product_name: str
quantity: float quantity: float # in Basiseinheiten
base_unit: BaseUnit base_unit: BaseUnit
best_before: date best_before: date
days_left: int 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): class GroupShoppingItem(BaseModel):

74
ios/README.md Normal file
View 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
View 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)
}
}

View 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
}
}
}

View 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
}
}
}

View 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
View 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>

View 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
View 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"
}
}

View 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
}
}
}

View 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
}
}

View 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)
}
}

View 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
View 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
View 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

View File

@@ -139,10 +139,13 @@ export const api = {
// Export / Import // Export / Import
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"), exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),
exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"), exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"),
importStock: (file) => { importStock: (file, mode = "add") => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); 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"), listUnits: () => request("/units"),

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { amountText, fmt, unitShort } from "../units"; import { amountText, articlePrimary, articleSecondary, fmt, relativeExpiry } from "../units";
export default function Dashboard() { export default function Dashboard() {
const [expiring, setExpiring] = useState([]); const [expiring, setExpiring] = useState([]);
@@ -77,18 +77,23 @@ export default function Dashboard() {
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
<thead> <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> </thead>
<tbody> <tbody>
{expiring.map((it) => ( {expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}> <tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
<td>{it.product_name}</td> <td>{it.product_name}</td>
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>
<td className="num"> <td className="num">
{it.days_left < 0 {articlePrimary(it)}
? <span className="badge danger">{-it.days_left}d überf.</span> {articleSecondary(it) && (
: it.days_left} <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> </td>
</tr> </tr>
))} ))}

View File

@@ -1,26 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { fmt, unitShort } from "../units"; import { articlePrimary, articleSecondary } from "../units";
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" }; 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() { export default function History() {
const [movements, setMovements] = useState([]); const [movements, setMovements] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -63,9 +47,9 @@ export default function History() {
</td> </td>
<td>{m.product_name}</td> <td>{m.product_name}</td>
<td className="num"> <td className="num">
{primaryAmount(m)} {articlePrimary(m)}
{secondaryAmount(m) && ( {articleSecondary(m) && (
<div className="muted small">{secondaryAmount(m)}</div> <div className="muted small">{articleSecondary(m)}</div>
)} )}
</td> </td>
<td className="muted">{m.username || ""}</td> <td className="muted">{m.username || ""}</td>

View File

@@ -5,7 +5,7 @@ import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup } from "../offUtils"; import { guessGroup } from "../offUtils";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units"; import { daysUntil, expiryRowClass, fmt, isExpired, relativeExpiry, unitShort } from "../units";
const EMPTY = { const EMPTY = {
barcode: "", name: "", brand: "", image_url: "", barcode: "", name: "", brand: "", image_url: "",
@@ -502,8 +502,12 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha
) : ( ) : (
<> <>
{l.best_before || ""} {l.best_before || ""}
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>} {cls && (
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>} <span className={`badge ${cls === "row-danger" ? "danger" : "warn"}`}
style={{ marginLeft: 6 }}>
{relativeExpiry(dLeft)}
</span>
)}
</> </>
)} )}
</td> </td>

View File

@@ -5,10 +5,19 @@ import Icon from "../components/Icon";
export default function Transfer() { export default function Transfer() {
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [mode, setMode] = useState("add");
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); 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) { async function download(kind) {
setError(null); setError(null);
try { try {
@@ -22,11 +31,18 @@ export default function Transfer() {
async function doImport(e) { async function doImport(e) {
e.preventDefault(); e.preventDefault();
if (!file) return; 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); setError(null);
setResult(null); setResult(null);
setBusy(true); setBusy(true);
try { try {
setResult(await api.importStock(file)); setResult(await api.importStock(file, mode));
setFile(null); setFile(null);
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
} catch (err) { } catch (err) {
@@ -68,9 +84,8 @@ export default function Transfer() {
<section className="card"> <section className="card">
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div> <div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
<p className="muted small mt-0"> <p className="muted small mt-0">
CSV oder JSON auswählen. Der Import <strong>ergänzt nur</strong>: unbekannte Produkte, CSV oder JSON auswählen. Unbekannte Produkte, Gruppen und Lagerorte werden immer
Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht angelegt was mit vorhandenen Beständen passiert, bestimmt der Modus.
oder überschrieben.
</p> </p>
<form onSubmit={doImport}> <form onSubmit={doImport}>
<label> <label>
@@ -78,6 +93,17 @@ export default function Transfer() {
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json" <input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
onChange={(e) => setFile(e.target.files?.[0] || null)} /> onChange={(e) => setFile(e.target.files?.[0] || null)} />
</label> </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}> <button className="btn primary" disabled={!file || busy}>
{busy ? "Importiere…" : "Importieren"} {busy ? "Importiere…" : "Importieren"}
</button> </button>
@@ -89,6 +115,7 @@ export default function Transfer() {
<span> <span>
Import abgeschlossen ({result.format?.toUpperCase()}):{" "} Import abgeschlossen ({result.format?.toUpperCase()}):{" "}
{result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt {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` : ""}. {result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}.
</span> </span>
</div> </div>

View File

@@ -53,6 +53,30 @@ export function isExpired(dateStr) {
return d != null && d < 0; 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. // CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist.
export function expiryRowClass(dateStr, warnDays = 7) { export function expiryRowClass(dateStr, warnDays = 7) {
const d = daysUntil(dateStr); const d = daysUntil(dateStr);
@@ -85,6 +109,23 @@ export function buildUnitOptions(product, units) {
return opts; 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". // Bestand eines Produkts in seiner Anzeigeeinheit, z.B. "1,5 Kilogramm".
export function stockLabel(product) { export function stockLabel(product) {
const factor = product.unit_factor || 1; const factor = product.unit_factor || 1;