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
co-authored by Claude Opus 4.8
parent de6c8632a1
commit 4bb9a10366
23 changed files with 1770 additions and 41 deletions
+67 -4
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
+5
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
+6 -1
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
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
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)
}
}
+149
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
}
}
}
+175
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
}
}
}
+210
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
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>
+51
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
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"
}
}
+136
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
}
}
}
+69
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
}
}
+93
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)
}
}
+107
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
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
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
+5 -2
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"),
+12 -7
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>
))} ))}
+4 -20
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>
+7 -3
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>
+31 -4
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>
+41
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;