iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten
iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
diesem Rechner ist kein Xcode vorhanden.
Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
fragt bei den zerstoerenden Modi nach.
Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
"vor 2 Wochen", dazu heute/morgen/gestern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,35 @@ def _parse_date(value) -> date | None:
|
|||||||
raise ValueError(f"Datum nicht lesbar: {text}")
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
74
ios/README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Project-Good – iOS-App
|
||||||
|
|
||||||
|
Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe
|
||||||
|
REST-API wie die Web-Oberfläche.
|
||||||
|
|
||||||
|
> **Stand:** Erste lauffähige Fassung (Login, Scanner, Einlagern mit mehreren
|
||||||
|
> Chargen/MHDs, Auslagern mit Chargenauswahl, Artikel anlegen aus Open Food Facts).
|
||||||
|
> Sie wurde bisher **nicht in Xcode kompiliert** – auf dem Entwicklungsrechner ist
|
||||||
|
> kein macOS/Xcode vorhanden. Rechne beim ersten Build mit Kleinigkeiten.
|
||||||
|
|
||||||
|
## Projekt in Xcode öffnen
|
||||||
|
|
||||||
|
### Variante A – mit XcodeGen (empfohlen)
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
cd ios
|
||||||
|
xcodegen generate
|
||||||
|
open ProjectGood.xcodeproj
|
||||||
|
```
|
||||||
|
`project.yml` beschreibt das Projekt vollständig (Bundle-ID, Info.plist,
|
||||||
|
Shortcuts, URL-Schema).
|
||||||
|
|
||||||
|
### Variante B – ohne XcodeGen
|
||||||
|
1. Xcode → *File ▸ New ▸ Project…* → **App**, Interface **SwiftUI**, Sprache **Swift**
|
||||||
|
2. Produktname `ProjectGood`, Bundle-ID z. B. `com.scarriffle.projectgood`
|
||||||
|
3. Die von Xcode erzeugte `ContentView.swift` und `…App.swift` löschen
|
||||||
|
4. Den Ordner `Sources/` per Drag & Drop ins Projekt ziehen („Copy items if needed")
|
||||||
|
5. In den Target-Einstellungen die mitgelieferte `Sources/Info.plist` als Info.plist setzen
|
||||||
|
(*Build Settings ▸ Info.plist File*) und *Generate Info.plist File* auf **No** stellen
|
||||||
|
|
||||||
|
## Auf dem iPhone installieren
|
||||||
|
1. iPhone per Kabel verbinden, in Xcode als Ziel auswählen
|
||||||
|
2. *Signing & Capabilities* → dein Apple-Developer-Team wählen
|
||||||
|
3. ▶︎ Run. Beim ersten Start auf dem iPhone unter
|
||||||
|
*Einstellungen ▸ Allgemein ▸ VPN & Geräteverwaltung* dem Entwickler vertrauen
|
||||||
|
|
||||||
|
## Erste Schritte in der App
|
||||||
|
Beim Start nach der Server-Adresse fragen lassen, z. B. `http://192.168.1.50:8080`
|
||||||
|
(dieselbe Adresse wie im Browser), dann mit deinem Benutzer anmelden. Adresse und
|
||||||
|
Token bleiben gespeichert – das Token liegt im Keychain.
|
||||||
|
|
||||||
|
## Home-Screen-Shortcuts
|
||||||
|
Es gibt zwei Wege direkt in den Scan-Bildschirm:
|
||||||
|
|
||||||
|
**Schnellaktionen** – langer Druck auf das App-Symbol zeigt „Einlagern" und
|
||||||
|
„Auslagern" (funktioniert ohne weitere Einrichtung).
|
||||||
|
|
||||||
|
**Eigene Symbole auf dem Home-Bildschirm** – über die Apple-App *Kurzbefehle*:
|
||||||
|
1. Kurzbefehle ▸ **+** ▸ Aktion *URL öffnen*
|
||||||
|
2. URL `projectgood://checkin` (bzw. `projectgood://checkout`) eintragen
|
||||||
|
3. Kurzbefehl benennen, dann ▸ *Zum Home-Bildschirm hinzufügen*
|
||||||
|
|
||||||
|
Beide Wege öffnen die App **direkt in der Kamera**.
|
||||||
|
|
||||||
|
## Aufbau der Quellen
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `ProjectGoodApp.swift` | App-Einstieg, Schnellaktionen, URL-Schema |
|
||||||
|
| `Session.swift` | Server-Adresse (UserDefaults) und Token (Keychain) |
|
||||||
|
| `APIClient.swift` | REST-Aufrufe gegen `/api/...` |
|
||||||
|
| `Models.swift` | Codable-Typen passend zu `backend/app/schemas.py` |
|
||||||
|
| `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) |
|
||||||
|
| `RootView.swift` | Startbildschirm und Routing |
|
||||||
|
| `LoginView.swift` | Server + Anmeldung |
|
||||||
|
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD |
|
||||||
|
| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
|
||||||
|
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) |
|
||||||
|
|
||||||
|
## Noch offen
|
||||||
|
- Einkaufsliste und „bald ablaufend" in der App
|
||||||
|
- Gruppenauswahl beim Anlegen (aktuell wird die Gruppe aus dem gescannten
|
||||||
|
EAN-Code übernommen, falls hinterlegt)
|
||||||
|
- Push-Benachrichtigungen bei ablaufenden Produkten
|
||||||
|
- App-Icon
|
||||||
127
ios/Sources/APIClient.swift
Normal file
127
ios/Sources/APIClient.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case notConfigured
|
||||||
|
case unauthorized
|
||||||
|
case server(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notConfigured: return "Server-Adresse fehlt. Bitte in den Einstellungen hinterlegen."
|
||||||
|
case .unauthorized: return "Nicht angemeldet oder Sitzung abgelaufen."
|
||||||
|
case .server(let message): return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schlanker Client für die Project-Good-REST-API.
|
||||||
|
/// Alle Pfade laufen über /api (wie im Web, siehe web/nginx.conf).
|
||||||
|
actor APIClient {
|
||||||
|
static let shared = APIClient()
|
||||||
|
|
||||||
|
private var baseURL: URL? { Session.shared.baseURL }
|
||||||
|
private var token: String? { Session.shared.token }
|
||||||
|
|
||||||
|
// MARK: - Basis
|
||||||
|
|
||||||
|
private func makeRequest(_ path: String, method: String = "GET") throws -> URLRequest {
|
||||||
|
guard let baseURL else { throw APIError.notConfigured }
|
||||||
|
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
|
||||||
|
throw APIError.notConfigured
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
try check(response, data: data)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func check(_ response: URLResponse, data: Data) throws {
|
||||||
|
guard let http = response as? HTTPURLResponse else { return }
|
||||||
|
guard !(200..<300).contains(http.statusCode) else { return }
|
||||||
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
// FastAPI liefert Fehler als {"detail": "..."}
|
||||||
|
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let detail = object["detail"] as? String {
|
||||||
|
throw APIError.server(detail)
|
||||||
|
}
|
||||||
|
throw APIError.server("Serverfehler (\(http.statusCode))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func jsonBody<T: Encodable>(_ request: inout URLRequest, _ value: T) throws {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONEncoder().encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
func login(username: String, password: String) async throws -> TokenResponse {
|
||||||
|
var request = try makeRequest("/auth/login", method: "POST")
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
var components = URLComponents()
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "username", value: username),
|
||||||
|
URLQueryItem(name: "password", value: password),
|
||||||
|
]
|
||||||
|
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
|
||||||
|
return try await send(request, as: TokenResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func me() async throws -> MeResponse {
|
||||||
|
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stammdaten
|
||||||
|
|
||||||
|
func units() async throws -> [Unit] {
|
||||||
|
try await send(try makeRequest("/units"), as: [Unit].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locations() async throws -> [StorageLocation] {
|
||||||
|
try await send(try makeRequest("/locations"), as: [StorageLocation].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchProducts(_ query: String) async throws -> [Product] {
|
||||||
|
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return try await send(try makeRequest("/products?q=\(escaped)"), as: [Product].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func product(id: Int) async throws -> Product {
|
||||||
|
try await send(try makeRequest("/products/\(id)"), as: Product.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookup(barcode: String) async throws -> LookupResult {
|
||||||
|
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
|
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProduct(_ payload: NewProductRequest) async throws -> Product {
|
||||||
|
var request = try makeRequest("/products", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: Product.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bestand
|
||||||
|
|
||||||
|
func lots(productId: Int) async throws -> [Lot] {
|
||||||
|
try await send(try makeRequest("/lots?product_id=\(productId)"), as: [Lot].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkInBatch(_ payload: BatchCheckInRequest) async throws -> StockResponse {
|
||||||
|
var request = try makeRequest("/stock/checkin/batch", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StockResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
|
||||||
|
var request = try makeRequest("/stock/checkout", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StockResponse.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ios/Sources/CheckInFormView.swift
Normal file
149
ios/Sources/CheckInFormView.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Mengen-Erfassung nach dem Scan: mehrere Chargen mit eigenem MHD.
|
||||||
|
struct CheckInFormView: View {
|
||||||
|
let product: Product
|
||||||
|
let units: [Unit]
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
var onDone: (String) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
struct Line: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var quantity: String = "1"
|
||||||
|
var hasDate: Bool = false
|
||||||
|
var bestBefore: Date = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var lines: [Line] = [Line()]
|
||||||
|
@State private var unit: String = ""
|
||||||
|
@State private var locationId: Int?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||||||
|
private var unitOptions: [String] {
|
||||||
|
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
||||||
|
if let size = product.packageSize, size > 0 {
|
||||||
|
options.append(product.packageLabel ?? "Packung")
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packageOptionName: String? {
|
||||||
|
guard let size = product.packageSize, size > 0 else { return nil }
|
||||||
|
return product.packageLabel ?? "Packung"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(product.name).font(.headline)
|
||||||
|
Text("Bestand: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Einheit") {
|
||||||
|
Picker("Einheit", selection: $unit) {
|
||||||
|
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
||||||
|
}
|
||||||
|
if !locations.isEmpty {
|
||||||
|
Picker("Lagerort", selection: $locationId) {
|
||||||
|
Text("– keiner –").tag(Int?.none)
|
||||||
|
ForEach(locations) { location in
|
||||||
|
Text(location.name).tag(Int?.some(location.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Chargen") {
|
||||||
|
ForEach($lines) { $line in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
TextField("Menge", text: $line.quantity)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
if lines.count > 1 {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
lines.removeAll { $0.id == line.id }
|
||||||
|
} label: { Image(systemName: "trash") }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("MHD angeben", isOn: $line.hasDate)
|
||||||
|
if line.hasDate {
|
||||||
|
DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
lines.append(Line())
|
||||||
|
} label: {
|
||||||
|
Label("Weitere Charge (anderes MHD)", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Speichern…" : "Einlagern") { Task { await submit() } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einlagern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||||
|
unit = packageOptionName ?? product.unitName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ value: Double) -> String {
|
||||||
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
error = nil
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
|
||||||
|
let payloadLines: [CheckInLine] = lines.compactMap { line in
|
||||||
|
guard let quantity = Double(line.quantity.replacingOccurrences(of: ",", with: ".")),
|
||||||
|
quantity > 0 else { return nil }
|
||||||
|
return CheckInLine(
|
||||||
|
quantity: quantity,
|
||||||
|
bestBefore: line.hasDate ? formatter.string(from: line.bestBefore) : nil,
|
||||||
|
locationId: locationId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard !payloadLines.isEmpty else {
|
||||||
|
error = "Bitte mindestens eine Menge angeben."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.checkInBatch(
|
||||||
|
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
||||||
|
)
|
||||||
|
let total = response.productStock / product.articleUnitFactor
|
||||||
|
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
ios/Sources/CheckInView.swift
Normal file
175
ios/Sources/CheckInView.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CheckInView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var paused = false
|
||||||
|
@State private var status: String?
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
@State private var product: Product?
|
||||||
|
@State private var suggestion: LookupResult.Suggestion?
|
||||||
|
@State private var suggestedGroupId: Int?
|
||||||
|
@State private var unknownCode: String?
|
||||||
|
@State private var manualCodeShown = false
|
||||||
|
@State private var manualCode = ""
|
||||||
|
@State private var units: [Unit] = []
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if let status { banner(status, color: .green) }
|
||||||
|
if let error { banner(error, color: .red) }
|
||||||
|
|
||||||
|
if let suggestion {
|
||||||
|
suggestionCard(suggestion)
|
||||||
|
} else if let unknownCode {
|
||||||
|
unknownCard(unknownCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
paused = true
|
||||||
|
manualCodeShown = true
|
||||||
|
} label: {
|
||||||
|
Label("EAN manuell", systemImage: "keyboard")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||||
|
product = created
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Artikel anlegen", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.background(.ultraThinMaterial.opacity(0.001))
|
||||||
|
}
|
||||||
|
.navigationTitle("Einlagern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
}
|
||||||
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
|
TextField("z.B. 8076809572569", text: $manualCode)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
Button("Suchen") {
|
||||||
|
let code = manualCode
|
||||||
|
manualCode = ""
|
||||||
|
Task { await resolve(code) }
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { paused = false }
|
||||||
|
}
|
||||||
|
.sheet(item: $product) { item in
|
||||||
|
NavigationStack {
|
||||||
|
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||||||
|
status = message
|
||||||
|
product = nil
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bausteine
|
||||||
|
|
||||||
|
private func banner(_ text: String, color: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(color.opacity(0.9))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(item.name).font(.headline)
|
||||||
|
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
NavigationLink {
|
||||||
|
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
||||||
|
suggestion: item) { created in
|
||||||
|
suggestion = nil
|
||||||
|
product = created
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unknownCard(_ code: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Unbekannter Code \(code)").font(.headline)
|
||||||
|
Text("Weder im Katalog noch bei Open Food Facts.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
NavigationLink {
|
||||||
|
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
|
||||||
|
unknownCode = nil
|
||||||
|
product = created
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Artikel anlegen", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logik
|
||||||
|
|
||||||
|
private func resolve(_ code: String) async {
|
||||||
|
paused = true
|
||||||
|
error = nil
|
||||||
|
status = nil
|
||||||
|
suggestion = nil
|
||||||
|
unknownCode = nil
|
||||||
|
do {
|
||||||
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
|
suggestedGroupId = result.groupId
|
||||||
|
if let existing = result.existingProduct {
|
||||||
|
product = existing
|
||||||
|
} else if let hint = result.suggestion {
|
||||||
|
suggestion = hint
|
||||||
|
} else {
|
||||||
|
unknownCode = code
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
ios/Sources/CheckOutView.swift
Normal file
210
ios/Sources/CheckOutView.swift
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CheckOutView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var paused = false
|
||||||
|
@State private var status: String?
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var product: Product?
|
||||||
|
@State private var units: [Unit] = []
|
||||||
|
@State private var manualCodeShown = false
|
||||||
|
@State private var manualCode = ""
|
||||||
|
@State private var searchShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
ScannerView(onCode: { code in Task { await resolve(code) } }, isPaused: $paused)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if let status { banner(status, color: .green) }
|
||||||
|
if let error { banner(error, color: .red) }
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
paused = true
|
||||||
|
searchShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Artikel suchen", systemImage: "magnifyingglass")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
paused = true
|
||||||
|
manualCodeShown = true
|
||||||
|
} label: {
|
||||||
|
Label("EAN manuell", systemImage: "keyboard")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Auslagern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
|
}
|
||||||
|
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
||||||
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
|
TextField("z.B. 8076809572569", text: $manualCode)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
Button("Suchen") {
|
||||||
|
let code = manualCode
|
||||||
|
manualCode = ""
|
||||||
|
Task { await resolve(code) }
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { paused = false }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $searchShown) {
|
||||||
|
NavigationStack {
|
||||||
|
ProductSearchView { picked in
|
||||||
|
searchShown = false
|
||||||
|
product = picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $product) { item in
|
||||||
|
NavigationStack {
|
||||||
|
CheckOutFormView(product: item, units: units) { message in
|
||||||
|
status = message
|
||||||
|
product = nil
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func banner(_ text: String, color: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(color.opacity(0.9))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ code: String) async {
|
||||||
|
paused = true
|
||||||
|
error = nil
|
||||||
|
status = nil
|
||||||
|
do {
|
||||||
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
|
if let existing = result.existingProduct {
|
||||||
|
product = existing
|
||||||
|
} else {
|
||||||
|
error = "Kein bekanntes Produkt zu diesem Code im Lager."
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auslagern: Menge, Einheit und optional eine bestimmte Charge (sonst FEFO).
|
||||||
|
struct CheckOutFormView: View {
|
||||||
|
let product: Product
|
||||||
|
let units: [Unit]
|
||||||
|
var onDone: (String) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var quantity = "1"
|
||||||
|
@State private var unit = ""
|
||||||
|
@State private var lots: [Lot] = []
|
||||||
|
@State private var lotId: Int?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private var unitOptions: [String] {
|
||||||
|
var options = units.filter { $0.kind == product.kind }.map(\.name)
|
||||||
|
if let size = product.packageSize, size > 0 {
|
||||||
|
options.append(product.packageLabel ?? "Packung")
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(product.name).font(.headline)
|
||||||
|
Text("Verfügbar: \(format(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Charge") {
|
||||||
|
Picker("Charge", selection: $lotId) {
|
||||||
|
Text("Automatisch (zuerst ablaufende)").tag(Int?.none)
|
||||||
|
ForEach(lots) { lot in
|
||||||
|
Text(label(for: lot)).tag(Int?.some(lot.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Menge") {
|
||||||
|
TextField("Menge", text: $quantity).keyboardType(.decimalPad)
|
||||||
|
Picker("Einheit", selection: $unit) {
|
||||||
|
ForEach(unitOptions, id: \.self) { Text($0).tag($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Speichern…" : "Auslagern") { Task { await submit() } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Auslagern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
unit = (product.packageSize ?? 0) > 0 ? (product.packageLabel ?? "Packung") : product.unitName
|
||||||
|
lots = (try? await APIClient.shared.lots(productId: product.id)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func label(for lot: Lot) -> String {
|
||||||
|
let amount = format(lot.quantity / product.articleUnitFactor)
|
||||||
|
let date = lot.bestBefore ?? "ohne MHD"
|
||||||
|
return "\(date) · \(amount) \(product.articleUnitLabel)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func format(_ value: Double) -> String {
|
||||||
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
error = nil
|
||||||
|
guard let amount = Double(quantity.replacingOccurrences(of: ",", with: ".")), amount > 0 else {
|
||||||
|
error = "Bitte eine Menge größer 0 angeben."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.checkOut(
|
||||||
|
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
||||||
|
)
|
||||||
|
let total = response.productStock / product.articleUnitFactor
|
||||||
|
onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
ios/Sources/Info.plist
Normal file
59
ios/Sources/Info.plist
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Project-Good</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
|
||||||
|
<!-- Kamera für den Barcode-Scan -->
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string>
|
||||||
|
|
||||||
|
<!-- Lokaler Server ohne HTTPS (Heimnetz) -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
||||||
|
<key>UIApplicationShortcutItems</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>com.scarriffle.projectgood.checkin</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Einlagern</string>
|
||||||
|
<key>UIApplicationShortcutItemIconType</key>
|
||||||
|
<string>UIApplicationShortcutIconTypeAdd</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationShortcutItemType</key>
|
||||||
|
<string>com.scarriffle.projectgood.checkout</string>
|
||||||
|
<key>UIApplicationShortcutItemTitle</key>
|
||||||
|
<string>Auslagern</string>
|
||||||
|
<key>UIApplicationShortcutItemIconType</key>
|
||||||
|
<string>UIApplicationShortcutIconTypeRemove</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- projectgood://checkin bzw. projectgood://checkout -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.scarriffle.projectgood</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>projectgood</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
51
ios/Sources/LoginView.swift
Normal file
51
ios/Sources/LoginView.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
|
@State private var server: String = Session.shared.baseURL?.absoluteString ?? ""
|
||||||
|
@State private var username = ""
|
||||||
|
@State private var password = ""
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var busy = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Server") {
|
||||||
|
TextField("http://192.168.1.10:8080", text: $server)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
}
|
||||||
|
Section("Anmeldung") {
|
||||||
|
TextField("Benutzername", text: $username)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
SecureField("Passwort", text: $password)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } }
|
||||||
|
.disabled(busy || server.isEmpty || username.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Project-Good")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func login() async {
|
||||||
|
error = nil
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
session.setServer(server)
|
||||||
|
do {
|
||||||
|
let token = try await APIClient.shared.login(username: username, password: password)
|
||||||
|
session.store(token: token.accessToken, username: token.username, isAdmin: token.role == "admin")
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
ios/Sources/Models.swift
Normal file
204
ios/Sources/Models.swift
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Entspricht den Schemas des Backends (backend/app/schemas.py).
|
||||||
|
|
||||||
|
struct TokenResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let role: String
|
||||||
|
let username: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case role, username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MeResponse: Codable {
|
||||||
|
let id: Int
|
||||||
|
let username: String
|
||||||
|
let role: String
|
||||||
|
|
||||||
|
var isAdmin: Bool { role == "admin" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Unit: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let kind: String
|
||||||
|
let factor: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let code: String
|
||||||
|
let note: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Product: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let barcode: String?
|
||||||
|
let name: String
|
||||||
|
let brand: String?
|
||||||
|
let imageUrl: String?
|
||||||
|
let baseUnit: String
|
||||||
|
let packageSize: Double?
|
||||||
|
let packageLabel: String?
|
||||||
|
let groupId: Int?
|
||||||
|
let minStock: Double?
|
||||||
|
let stock: Double
|
||||||
|
let expiredCount: Int
|
||||||
|
let kind: String
|
||||||
|
let unitName: String
|
||||||
|
let unitFactor: Double
|
||||||
|
let barcodes: [BarcodeEntry]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, barcode, name, brand, stock, kind, barcodes
|
||||||
|
case imageUrl = "image_url"
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case packageLabel = "package_label"
|
||||||
|
case groupId = "group_id"
|
||||||
|
case minStock = "min_stock"
|
||||||
|
case expiredCount = "expired_count"
|
||||||
|
case unitName = "unit_name"
|
||||||
|
case unitFactor = "unit_factor"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
||||||
|
var articleUnitLabel: String {
|
||||||
|
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
|
||||||
|
return unitName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faktor, um Basiseinheiten in Artikeleinheiten umzurechnen.
|
||||||
|
var articleUnitFactor: Double {
|
||||||
|
if let size = packageSize, size > 0 { return size }
|
||||||
|
return unitFactor == 0 ? 1 : unitFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LookupResult: Codable {
|
||||||
|
let found: Bool
|
||||||
|
let existingProduct: Product?
|
||||||
|
let suggestion: Suggestion?
|
||||||
|
let groupId: Int?
|
||||||
|
let groupName: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case found, suggestion
|
||||||
|
case existingProduct = "existing_product"
|
||||||
|
case groupId = "group_id"
|
||||||
|
case groupName = "group_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Suggestion: Codable {
|
||||||
|
let barcode: String?
|
||||||
|
let name: String
|
||||||
|
let brand: String?
|
||||||
|
let imageUrl: String?
|
||||||
|
let baseUnit: String?
|
||||||
|
let packageSize: Double?
|
||||||
|
let quantityText: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case barcode, name, brand
|
||||||
|
case imageUrl = "image_url"
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case quantityText = "quantity_text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lot: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let productId: Int
|
||||||
|
let quantity: Double
|
||||||
|
let bestBefore: String?
|
||||||
|
let locationId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, quantity
|
||||||
|
case productId = "product_id"
|
||||||
|
case bestBefore = "best_before"
|
||||||
|
case locationId = "location_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let parentId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case parentId = "parent_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CheckInLine: Codable {
|
||||||
|
let quantity: Double
|
||||||
|
let bestBefore: String?
|
||||||
|
let locationId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case quantity
|
||||||
|
case bestBefore = "best_before"
|
||||||
|
case locationId = "location_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BatchCheckInRequest: Codable {
|
||||||
|
let productId: Int
|
||||||
|
let unit: String
|
||||||
|
let lines: [CheckInLine]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case unit, lines
|
||||||
|
case productId = "product_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CheckOutRequest: Codable {
|
||||||
|
let productId: Int
|
||||||
|
let quantity: Double
|
||||||
|
let unit: String
|
||||||
|
let lotId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case quantity, unit
|
||||||
|
case productId = "product_id"
|
||||||
|
case lotId = "lot_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StockResponse: Codable {
|
||||||
|
let productStock: Double
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case productStock = "product_stock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewProductRequest: Codable {
|
||||||
|
let barcode: String?
|
||||||
|
let name: String
|
||||||
|
let brand: String?
|
||||||
|
let imageUrl: String?
|
||||||
|
let baseUnit: String
|
||||||
|
let packageSize: Double?
|
||||||
|
let packageLabel: String?
|
||||||
|
let groupId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case barcode, name, brand
|
||||||
|
case imageUrl = "image_url"
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case packageLabel = "package_label"
|
||||||
|
case groupId = "group_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
ios/Sources/ProductViews.swift
Normal file
136
ios/Sources/ProductViews.swift
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
||||||
|
struct ProductSearchView: View {
|
||||||
|
var onPick: (Product) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var results: [Product] = []
|
||||||
|
@State private var busy = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(results) { product in
|
||||||
|
Button {
|
||||||
|
onPick(product)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(product.name)
|
||||||
|
Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if results.isEmpty && !busy {
|
||||||
|
Text("Keine Treffer").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $query, prompt: "Artikel suchen")
|
||||||
|
.onSubmit(of: .search) { Task { await search() } }
|
||||||
|
.task { await search() }
|
||||||
|
.onChange(of: query) { _, _ in Task { await search() } }
|
||||||
|
.navigationTitle("Artikel suchen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatted(_ value: Double) -> String {
|
||||||
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func search() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
results = (try? await APIClient.shared.searchProducts(query)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt.
|
||||||
|
struct ProductFormView: View {
|
||||||
|
let prefillBarcode: String?
|
||||||
|
let groupId: Int?
|
||||||
|
var suggestion: LookupResult.Suggestion?
|
||||||
|
var onCreated: (Product) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var barcode = ""
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var brand = ""
|
||||||
|
@State private var baseUnit = "piece"
|
||||||
|
@State private var packageSize = ""
|
||||||
|
@State private var packageLabel = ""
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||||||
|
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Artikel") {
|
||||||
|
TextField("Barcode", text: $barcode).keyboardType(.numberPad)
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
TextField("Marke", text: $brand)
|
||||||
|
}
|
||||||
|
Section("Einheit") {
|
||||||
|
Picker("Basiseinheit", selection: $baseUnit) {
|
||||||
|
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||||||
|
}
|
||||||
|
TextField("Packungsgröße (in Basiseinheit)", text: $packageSize)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
Picker("Bezeichnung", selection: $packageLabel) {
|
||||||
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } }
|
||||||
|
.disabled(busy || name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Neuer Artikel")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: prefill)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prefill() {
|
||||||
|
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||||
|
if let suggestion {
|
||||||
|
name = suggestion.name
|
||||||
|
brand = suggestion.brand ?? ""
|
||||||
|
baseUnit = suggestion.baseUnit ?? "piece"
|
||||||
|
if let size = suggestion.packageSize { packageSize = String(size) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() async {
|
||||||
|
error = nil
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let product = try await APIClient.shared.createProduct(
|
||||||
|
NewProductRequest(
|
||||||
|
barcode: barcode.isEmpty ? nil : barcode,
|
||||||
|
name: name,
|
||||||
|
brand: brand.isEmpty ? nil : brand,
|
||||||
|
imageUrl: suggestion?.imageUrl,
|
||||||
|
baseUnit: baseUnit,
|
||||||
|
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||||
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
|
groupId: groupId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onCreated(product)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
ios/Sources/ProjectGoodApp.swift
Normal file
69
ios/Sources/ProjectGoodApp.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird.
|
||||||
|
enum Route: String {
|
||||||
|
case checkin
|
||||||
|
case checkout
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Router: ObservableObject {
|
||||||
|
@Published var route: Route?
|
||||||
|
|
||||||
|
func handle(shortcut type: String) {
|
||||||
|
if type.hasSuffix("checkin") { route = .checkin }
|
||||||
|
if type.hasSuffix("checkout") { route = .checkout }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// projectgood://checkin bzw. projectgood://checkout
|
||||||
|
func handle(url: URL) {
|
||||||
|
guard url.scheme == "projectgood" else { return }
|
||||||
|
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
|
||||||
|
if let route = Route(rawValue: target) { self.route = route }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ProjectGoodApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@StateObject private var session = Session.shared
|
||||||
|
@StateObject private var router = Router()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environmentObject(session)
|
||||||
|
.environmentObject(router)
|
||||||
|
.onOpenURL { router.handle(url: $0) }
|
||||||
|
.onAppear {
|
||||||
|
if let type = AppDelegate.pendingShortcut {
|
||||||
|
router.handle(shortcut: type)
|
||||||
|
AppDelegate.pendingShortcut = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
||||||
|
if let type = note.object as? String { router.handle(shortcut: type) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart).
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
static let shortcutNotification = Notification.Name("ProjectGoodShortcut")
|
||||||
|
static var pendingShortcut: String?
|
||||||
|
|
||||||
|
func application(_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
|
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
||||||
|
AppDelegate.pendingShortcut = item.type
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication,
|
||||||
|
performActionFor shortcutItem: UIApplicationShortcutItem) async -> Bool {
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ios/Sources/RootView.swift
Normal file
93
ios/Sources/RootView.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@EnvironmentObject private var router: Router
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if session.isLoggedIn {
|
||||||
|
HomeView()
|
||||||
|
} else {
|
||||||
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
||||||
|
.fullScreenCover(item: $router.route) { route in
|
||||||
|
NavigationStack {
|
||||||
|
switch route {
|
||||||
|
case .checkin: CheckInView()
|
||||||
|
case .checkout: CheckOutView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Route: Identifiable {
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@EnvironmentObject private var router: Router
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Button {
|
||||||
|
router.route = .checkin
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Einlagern", subtitle: "Barcode scannen und Bestand erfassen",
|
||||||
|
systemImage: "arrow.down.to.line")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
router.route = .checkout
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||||
|
systemImage: "arrow.up.from.line")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Project-Good")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Text("Angemeldet als \(session.username)")
|
||||||
|
Button("Abmelden", role: .destructive) { session.logout() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "person.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActionTile: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Color.accentColor.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.headline)
|
||||||
|
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
ios/Sources/ScannerView.swift
Normal file
107
ios/Sources/ScannerView.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Live-Kamerabild mit Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR).
|
||||||
|
/// Meldet jeden erkannten Code einmal – erst nach `resume()` wieder.
|
||||||
|
struct ScannerView: UIViewControllerRepresentable {
|
||||||
|
var onCode: (String) -> Void
|
||||||
|
@Binding var isPaused: Bool
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||||
|
let controller = ScannerViewController()
|
||||||
|
controller.delegate = context.coordinator
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
||||||
|
context.coordinator.isPaused = isPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
||||||
|
let onCode: (String) -> Void
|
||||||
|
var isPaused: Bool = false
|
||||||
|
private var lastCode: String?
|
||||||
|
private var lastTime: Date = .distantPast
|
||||||
|
|
||||||
|
init(onCode: @escaping (String) -> Void) { self.onCode = onCode }
|
||||||
|
|
||||||
|
func scanner(_ controller: ScannerViewController, didFind code: String) {
|
||||||
|
guard !isPaused else { return }
|
||||||
|
// Entprellen: derselbe Code nicht mehrfach in kurzer Folge.
|
||||||
|
if code == lastCode, Date().timeIntervalSince(lastTime) < 2 { return }
|
||||||
|
lastCode = code
|
||||||
|
lastTime = Date()
|
||||||
|
AudioServicesPlaySystemSound(1057)
|
||||||
|
onCode(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol ScannerViewControllerDelegate: AnyObject {
|
||||||
|
func scanner(_ controller: ScannerViewController, didFind code: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
weak var delegate: ScannerViewControllerDelegate?
|
||||||
|
|
||||||
|
private let session = AVCaptureSession()
|
||||||
|
private var preview: AVCaptureVideoPreviewLayer?
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
guard granted else { return }
|
||||||
|
DispatchQueue.main.async { self?.configure() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure() {
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
|
session.canAddInput(input) else { return }
|
||||||
|
session.addInput(input)
|
||||||
|
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard session.canAddOutput(output) else { return }
|
||||||
|
session.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
|
output.metadataObjectTypes = [.ean13, .ean8, .upce, .code128, .code39, .qr]
|
||||||
|
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
layer.frame = view.bounds
|
||||||
|
view.layer.addSublayer(layer)
|
||||||
|
preview = layer
|
||||||
|
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !session.isRunning else { return }
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.startRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard session.isRunning else { return }
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.session.stopRunning() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); start() }
|
||||||
|
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated); stop() }
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
preview?.frame = view.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||||
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection) {
|
||||||
|
guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
|
let value = object.stringValue else { return }
|
||||||
|
delegate?.scanner(self, didFind: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
ios/Sources/Session.swift
Normal file
95
ios/Sources/Session.swift
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
||||||
|
/// die Server-URL in den UserDefaults (nicht geheim).
|
||||||
|
final class Session: ObservableObject {
|
||||||
|
static let shared = Session()
|
||||||
|
|
||||||
|
private let urlKey = "server_url"
|
||||||
|
private let keychainAccount = "project-good-token"
|
||||||
|
|
||||||
|
@Published private(set) var baseURL: URL?
|
||||||
|
@Published private(set) var token: String?
|
||||||
|
@Published var username: String = ""
|
||||||
|
@Published var isAdmin: Bool = false
|
||||||
|
|
||||||
|
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
||||||
|
baseURL = Session.normalize(stored)
|
||||||
|
}
|
||||||
|
token = Keychain.read(account: keychainAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
||||||
|
/// Pfade ("api/...") korrekt aufgelöst werden.
|
||||||
|
static func normalize(_ raw: String) -> URL? {
|
||||||
|
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
if !text.contains("://") { text = "http://" + text }
|
||||||
|
if !text.hasSuffix("/") { text += "/" }
|
||||||
|
return URL(string: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setServer(_ raw: String) {
|
||||||
|
guard let url = Session.normalize(raw) else { return }
|
||||||
|
baseURL = url
|
||||||
|
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(token newToken: String, username name: String, isAdmin admin: Bool) {
|
||||||
|
token = newToken
|
||||||
|
username = name
|
||||||
|
isAdmin = admin
|
||||||
|
Keychain.write(newToken, account: keychainAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() {
|
||||||
|
token = nil
|
||||||
|
username = ""
|
||||||
|
isAdmin = false
|
||||||
|
Keychain.delete(account: keychainAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||||||
|
enum Keychain {
|
||||||
|
private static let service = "com.scarriffle.projectgood"
|
||||||
|
|
||||||
|
static func write(_ value: String, account: String) {
|
||||||
|
delete(account: account)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecValueData as String: Data(value.utf8),
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||||
|
]
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func read(account: String) -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(account: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ios/project.yml
Normal file
43
ios/project.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# XcodeGen-Spezifikation – erzeugt das Xcode-Projekt aus diesen Quellen.
|
||||||
|
# brew install xcodegen && cd ios && xcodegen generate && open ProjectGood.xcodeproj
|
||||||
|
# Alternativ kann man in Xcode ein leeres App-Projekt anlegen und den Ordner
|
||||||
|
# "Sources" hineinziehen (siehe README.md).
|
||||||
|
name: ProjectGood
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.scarriffle
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "16.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
|
||||||
|
targets:
|
||||||
|
ProjectGood:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: Sources
|
||||||
|
info:
|
||||||
|
path: Sources/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: Project-Good
|
||||||
|
CFBundleShortVersionString: "1.0"
|
||||||
|
CFBundleVersion: "1"
|
||||||
|
UILaunchScreen: {}
|
||||||
|
NSCameraUsageDescription: >-
|
||||||
|
Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.
|
||||||
|
UIApplicationShortcutItems:
|
||||||
|
- UIApplicationShortcutItemType: com.scarriffle.projectgood.checkin
|
||||||
|
UIApplicationShortcutItemTitle: Einlagern
|
||||||
|
UIApplicationShortcutItemIconType: UIApplicationShortcutIconTypeAdd
|
||||||
|
- UIApplicationShortcutItemType: com.scarriffle.projectgood.checkout
|
||||||
|
UIApplicationShortcutItemTitle: Auslagern
|
||||||
|
UIApplicationShortcutItemIconType: UIApplicationShortcutIconTypeRemove
|
||||||
|
CFBundleURLTypes:
|
||||||
|
- CFBundleURLName: com.scarriffle.projectgood
|
||||||
|
CFBundleURLSchemes: [projectgood]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffle.projectgood
|
||||||
|
MARKETING_VERSION: "1.0"
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
@@ -139,10 +139,13 @@ export const api = {
|
|||||||
// Export / Import
|
// 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"),
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user