Compare commits
2 Commits
4730d791e8
...
ff158a1e5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff158a1e5f | ||
|
|
794a81f58b |
@@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Location, User
|
from ..models import Location, User
|
||||||
from ..schemas import LocationCreate, LocationOut
|
from ..schemas import LocationCreate, LocationOut, LocationUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||||
|
|
||||||
@@ -29,6 +30,33 @@ def create_location(
|
|||||||
return loc
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{location_id}", response_model=LocationOut)
|
||||||
|
def update_location(
|
||||||
|
location_id: int,
|
||||||
|
payload: LocationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> Location:
|
||||||
|
"""Umbenennen. Chargen haengen an der ID, behalten ihren Lagerort also."""
|
||||||
|
loc = db.get(Location, location_id)
|
||||||
|
if loc is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
|
name = payload.name.strip()
|
||||||
|
doppelt = (
|
||||||
|
db.query(Location)
|
||||||
|
.filter(func.lower(Location.name) == name.lower(), Location.id != location_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits")
|
||||||
|
|
||||||
|
loc.name = name
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loc)
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_location(
|
def delete_location(
|
||||||
location_id: int,
|
location_id: int,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Group, Product, Unit, User
|
from ..models import Group, Product, Unit, User
|
||||||
from ..schemas import UnitCreate, UnitOut
|
from ..schemas import UnitCreate, UnitOut, UnitUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
|
||||||
@@ -33,6 +33,34 @@ def create_unit(
|
|||||||
return unit
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{unit_id}", response_model=UnitOut)
|
||||||
|
def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> Unit:
|
||||||
|
"""Umbenennen – auch bei eingebauten Einheiten, wie bei den Gebinden.
|
||||||
|
Produkte und Gruppen verweisen ueber die ID und bleiben unberuehrt."""
|
||||||
|
unit = db.get(Unit, unit_id)
|
||||||
|
if unit is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einheit nicht gefunden")
|
||||||
|
|
||||||
|
name = payload.name.strip()
|
||||||
|
doppelt = (
|
||||||
|
db.query(Unit)
|
||||||
|
.filter(func.lower(Unit.name) == name.lower(), Unit.id != unit_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Einheit existiert bereits")
|
||||||
|
|
||||||
|
unit.name = name
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_unit(
|
def delete_unit(
|
||||||
unit_id: int,
|
unit_id: int,
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ class UnitCreate(BaseModel):
|
|||||||
factor: float = Field(gt=0)
|
factor: float = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitUpdate(BaseModel):
|
||||||
|
"""Nur der Name. Art und Faktor bleiben fest – sie stecken bereits in
|
||||||
|
umgerechneten Bestaenden, eine Aenderung wuerde die still verfaelschen."""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
||||||
class ApiTokenOut(BaseModel):
|
class ApiTokenOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -156,6 +163,10 @@ class LocationCreate(BaseModel):
|
|||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
# ---- Gebinde (Packung, Glas, …) ----
|
# ---- Gebinde (Packung, Glas, …) ----
|
||||||
class PackageTypeCreate(BaseModel):
|
class PackageTypeCreate(BaseModel):
|
||||||
singular: str = Field(min_length=1, max_length=32)
|
singular: str = Field(min_length=1, max_length=32)
|
||||||
|
|||||||
106
backend/tests/test_stammdaten_umbenennen.py
Normal file
106
backend/tests/test_stammdaten_umbenennen.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Umbenennen von Lagerorten und Einheiten.
|
||||||
|
|
||||||
|
Bis dahin gab es nur Anlegen und Loeschen. Wer sich vertippt hatte, musste den
|
||||||
|
Eintrag wegwerfen und neu anlegen - und verlor dabei die Zuordnung der Chargen
|
||||||
|
bzw. Produkte. Diese Tests halten fest, dass Umbenennen die Verweise behaelt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Lot, Product, Role, Unit, User
|
||||||
|
from app.routers import locations, units
|
||||||
|
from app.schemas import LocationCreate, LocationUpdate, UnitCreate, UnitUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Lagerorte ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_umbenennen_behaelt_die_chargen(db, admin, rice):
|
||||||
|
keller = locations.create_location(LocationCreate(name="Keler"), db=db, _=admin)
|
||||||
|
charge = Lot(product_id=rice.id, quantity=500, location_id=keller.id)
|
||||||
|
db.add(charge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
locations.update_location(keller.id, LocationUpdate(name="Keller"), db=db, _=admin)
|
||||||
|
|
||||||
|
db.refresh(charge)
|
||||||
|
assert charge.location_id == keller.id
|
||||||
|
assert db.get(type(keller), keller.id).name == "Keller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_doppelter_name_wird_abgelehnt(db, admin):
|
||||||
|
locations.create_location(LocationCreate(name="Keller"), db=db, _=admin)
|
||||||
|
speis = locations.create_location(LocationCreate(name="Speis"), db=db, _=admin)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
# Gross-/Kleinschreibung darf keinen Unterschied machen.
|
||||||
|
locations.update_location(speis.id, LocationUpdate(name="keller"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_gleicher_name_bleibt_erlaubt(db, admin):
|
||||||
|
"""Nur die Schreibweise aendern darf nicht am eigenen Eintrag scheitern."""
|
||||||
|
keller = locations.create_location(LocationCreate(name="keller"), db=db, _=admin)
|
||||||
|
geaendert = locations.update_location(
|
||||||
|
keller.id, LocationUpdate(name="Keller"), db=db, _=admin
|
||||||
|
)
|
||||||
|
assert geaendert.name == "Keller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_unbekannt(db, admin):
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
locations.update_location(999, LocationUpdate(name="Keller"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einheiten ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_umbenennen_behaelt_die_produkte(db, admin):
|
||||||
|
einheit = units.create_unit(
|
||||||
|
UnitCreate(name="Beutle", kind="weight", factor=1000), db=db, _=admin
|
||||||
|
)
|
||||||
|
produkt = Product(name="Mehl", base_unit=BaseUnit.gram, display_unit_id=einheit.id)
|
||||||
|
db.add(produkt)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
units.update_unit(einheit.id, UnitUpdate(name="Beutel"), db=db, _=admin)
|
||||||
|
|
||||||
|
db.refresh(produkt)
|
||||||
|
assert produkt.display_unit_id == einheit.id
|
||||||
|
assert db.get(Unit, einheit.id).name == "Beutel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eingebaute_einheit_laesst_sich_umbenennen(db, admin):
|
||||||
|
"""Anders als beim Loeschen ist Umbenennen auch eingebaut erlaubt -
|
||||||
|
genauso wie bei den Gebinden."""
|
||||||
|
gramm = db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
assert gramm.is_builtin is True
|
||||||
|
|
||||||
|
units.update_unit(gramm.id, UnitUpdate(name="Gramm (g)"), db=db, _=admin)
|
||||||
|
assert db.get(Unit, gramm.id).name == "Gramm (g)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_doppelter_name_wird_abgelehnt(db, admin):
|
||||||
|
einheit = units.create_unit(
|
||||||
|
UnitCreate(name="Beutel", kind="weight", factor=1000), db=db, _=admin
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
units.update_unit(einheit.id, UnitUpdate(name="gramm"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_unbekannt(db, admin):
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
units.update_unit(999, UnitUpdate(name="Beutel"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 404
|
||||||
@@ -179,8 +179,105 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteLot(id: Int) async throws {
|
func deleteLot(id: Int) async throws {
|
||||||
let request = try makeRequest("/lots/\(id)", method: "DELETE")
|
try await sendNoContent(try makeRequest("/lots/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
|
||||||
|
private func sendNoContent(_ request: URLRequest) async throws {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
try check(response, data: data) // 204: kein Rumpf zum Auswerten
|
try check(response, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uebersicht und Verlauf
|
||||||
|
|
||||||
|
func dashboardStats() async throws -> DashboardStats {
|
||||||
|
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neueste zuerst. `productId` filtert auf einen Artikel.
|
||||||
|
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
|
||||||
|
var path = "/movements?limit=\(limit)"
|
||||||
|
if let productId { path += "&product_id=\(productId)" }
|
||||||
|
return try await send(try makeRequest(path), as: [Movement].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stammdaten
|
||||||
|
|
||||||
|
func createLocation(_ payload: NewLocationRequest) async throws -> StorageLocation {
|
||||||
|
var request = try makeRequest("/locations", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StorageLocation.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameLocation(id: Int, name: String) async throws -> StorageLocation {
|
||||||
|
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: StorageLocation.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLocation(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUnit(_ payload: NewUnitRequest) async throws -> Unit {
|
||||||
|
var request = try makeRequest("/units", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: Unit.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameUnit(id: Int, name: String) async throws -> Unit {
|
||||||
|
var request = try makeRequest("/units/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: Unit.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUnit(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/units/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageTypes() async throws -> [PackageType] {
|
||||||
|
try await send(try makeRequest("/package-types"), as: [PackageType].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPackageType(_ payload: NewPackageTypeRequest) async throws -> PackageType {
|
||||||
|
var request = try makeRequest("/package-types", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: PackageType.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePackageType(id: Int, _ payload: PackageTypeUpdateRequest) async throws -> PackageType {
|
||||||
|
var request = try makeRequest("/package-types/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: PackageType.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePackageType(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/package-types/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCategory(_ payload: NewCategoryRequest) async throws -> CategoryItem {
|
||||||
|
var request = try makeRequest("/categories", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: CategoryItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameCategory(id: Int, name: String) async throws -> CategoryItem {
|
||||||
|
var request = try makeRequest("/categories/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: CategoryItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCategory(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameGroup(id: Int, name: String) async throws -> GroupItem {
|
||||||
|
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: GroupItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroup(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/groups/\(id)", method: "DELETE"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,60 @@ final class DisplaySettings: ObservableObject {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Zeitstempel
|
||||||
|
|
||||||
|
/// Zeitpunkte der API ("2026-07-23T16:20:59.510258").
|
||||||
|
///
|
||||||
|
/// Der Server erzeugt sie mit `datetime.now(timezone.utc)`, haengt an die
|
||||||
|
/// Antwort aber keine Zeitzone an, solange SQLite dahinter steht - Postgres
|
||||||
|
/// liefert dagegen ein Offset mit. Deshalb erst der Versuch mit Offset,
|
||||||
|
/// danach die nackte Form, die **als UTC** gelesen wird. Ohne das zeigte die
|
||||||
|
/// App die Uhrzeit um die eigene Zeitverschiebung daneben.
|
||||||
|
static func parseTimestamp(_ raw: String) -> Date? {
|
||||||
|
for optionen: ISO8601DateFormatter.Options in [
|
||||||
|
[.withInternetDateTime, .withFractionalSeconds], [.withInternetDateTime],
|
||||||
|
] {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = optionen
|
||||||
|
if let date = formatter.date(from: raw) { return date }
|
||||||
|
}
|
||||||
|
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
formatter.dateFormat = muster
|
||||||
|
if let date = formatter.date(from: raw) { return date }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uhrzeit einer Bewegung ("17:22").
|
||||||
|
func formatTime(_ raw: String) -> String {
|
||||||
|
guard let date = DisplaySettings.parseTimestamp(raw) else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ueberschrift einer Tagesgruppe. "Heute" und "Gestern" sind schneller zu
|
||||||
|
/// erfassen als ein Datum.
|
||||||
|
func formatDay(_ raw: String) -> String {
|
||||||
|
guard let date = DisplaySettings.parseTimestamp(raw) else { return raw }
|
||||||
|
let kalender = Calendar.current
|
||||||
|
if kalender.isDateInToday(date) { return "Heute" }
|
||||||
|
if kalender.isDateInYesterday(date) { return "Gestern" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
|
formatter.dateFormat = DisplaySettings.pattern(for: dateFormat)
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tagesschluessel zum Gruppieren - unabhaengig von der Schreibweise.
|
||||||
|
static func dayKey(_ raw: String) -> String {
|
||||||
|
String(raw.prefix(10))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Einheiten
|
// MARK: - Einheiten
|
||||||
|
|
||||||
/// Kanonische Basiseinheit der API auf Deutsch.
|
/// Kanonische Basiseinheit der API auf Deutsch.
|
||||||
|
|||||||
121
ios/Sources/HistoryView.swift
Normal file
121
ios/Sources/HistoryView.swift
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bewegungsverlauf: wer hat wann was ein- und ausgelagert.
|
||||||
|
///
|
||||||
|
/// Dieselbe Ansicht dient dem Gesamtverlauf und dem Verlauf eines einzelnen
|
||||||
|
/// Artikels - der Unterschied ist nur `productId`.
|
||||||
|
struct HistoryView: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
var productId: Int?
|
||||||
|
|
||||||
|
@State private var movements: [Movement] = []
|
||||||
|
@State private var limit = 100
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(tage, id: \.key) { tag in
|
||||||
|
Section(display.formatDay(tag.eintraege[0].createdAt)) {
|
||||||
|
ForEach(tag.eintraege) { movement in
|
||||||
|
MovementRow(movement: movement, showProduct: productId == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if movements.isEmpty && !busy {
|
||||||
|
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachladen erst anbieten, wenn die aktuelle Menge auch voll ist.
|
||||||
|
if movements.count >= limit {
|
||||||
|
Button("Mehr laden") {
|
||||||
|
limit += 100
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Verlauf")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Tag {
|
||||||
|
let key: String
|
||||||
|
let eintraege: [Movement]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach Tagen gruppiert, neueste zuerst. Die API liefert bereits sortiert,
|
||||||
|
/// deshalb reicht es, der Reihe nach zu sammeln.
|
||||||
|
private var tage: [Tag] {
|
||||||
|
var result: [Tag] = []
|
||||||
|
for movement in movements {
|
||||||
|
let key = DisplaySettings.dayKey(movement.createdAt)
|
||||||
|
if let last = result.last, last.key == key {
|
||||||
|
result[result.count - 1] = Tag(key: key, eintraege: last.eintraege + [movement])
|
||||||
|
} else {
|
||||||
|
result.append(Tag(key: key, eintraege: [movement]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
movements = try await APIClient.shared.movements(limit: limit, productId: productId)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MovementRow: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
let movement: Movement
|
||||||
|
var showProduct = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: movement.isIncoming ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(movement.isIncoming ? Color.green : Color.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if showProduct {
|
||||||
|
Text(movement.productName)
|
||||||
|
}
|
||||||
|
Text(menge)
|
||||||
|
.font(showProduct ? .caption : .body)
|
||||||
|
.foregroundStyle(showProduct ? .secondary : .primary)
|
||||||
|
if let note = movement.note, !note.isEmpty {
|
||||||
|
Text(note).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(display.formatTime(movement.createdAt))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let user = movement.username, !user.isEmpty {
|
||||||
|
Text(user).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorzeichen macht die Richtung auch ohne Farbe lesbar.
|
||||||
|
private var menge: String {
|
||||||
|
let zeichen = movement.isIncoming ? "+" : "−"
|
||||||
|
return zeichen + display.amountText(movement.quantity,
|
||||||
|
packageSize: movement.packageSize,
|
||||||
|
baseUnit: movement.baseUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import SwiftUI
|
|||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
@State private var server: String = Session.shared.baseURL?.absoluteString ?? ""
|
@State private var server: String = ""
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
||||||
|
@State private var manageShown = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -18,6 +19,15 @@ struct LoginView: View {
|
|||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
|
// Mit mehreren Servern ist das hier der Weg zurueck, wenn man
|
||||||
|
// am falschen gelandet ist.
|
||||||
|
if session.profiles.count > 1 {
|
||||||
|
Picker("Ausgewählt", selection: profileSelection) {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
Text(profile.name).tag(profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section("Anmeldung") {
|
Section("Anmeldung") {
|
||||||
TextField("Benutzername", text: $username)
|
TextField("Benutzername", text: $username)
|
||||||
@@ -40,9 +50,32 @@ struct LoginView: View {
|
|||||||
.disabled(busy || server.isEmpty || username.isEmpty)
|
.disabled(busy || server.isEmpty || username.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Vorrania")
|
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Server verwalten", systemImage: "server.rack") { manageShown = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||||
|
}
|
||||||
|
// Beim Start und bei jedem Serverwechsel die Felder auf das aktive
|
||||||
|
// Profil setzen - sonst steht die Adresse des vorigen Servers da.
|
||||||
|
.task(id: session.activeProfileID) { syncFromProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var profileSelection: Binding<UUID> {
|
||||||
|
Binding(
|
||||||
|
get: { session.activeProfileID ?? session.profiles.first?.id ?? UUID() },
|
||||||
|
set: { session.switchTo($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncFromProfile() {
|
||||||
|
server = session.activeProfile?.urlString ?? ""
|
||||||
|
username = session.activeProfile?.username ?? ""
|
||||||
|
password = ""
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func login() async {
|
private func login() async {
|
||||||
error = nil
|
error = nil
|
||||||
|
|||||||
552
ios/Sources/MasterDataViews.swift
Normal file
552
ios/Sources/MasterDataViews.swift
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Verwaltungs-Tab: alles, was Stammdaten sind, plus die Serverliste.
|
||||||
|
struct AdminTabView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@State private var manageShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Stammdaten") {
|
||||||
|
NavigationLink { LocationsView() } label: {
|
||||||
|
Label("Lagerorte", systemImage: "mappin.and.ellipse")
|
||||||
|
}
|
||||||
|
NavigationLink { UnitsView() } label: {
|
||||||
|
Label("Einheiten", systemImage: "ruler")
|
||||||
|
}
|
||||||
|
NavigationLink { PackageTypesView() } label: {
|
||||||
|
Label("Gebinde", systemImage: "shippingbox")
|
||||||
|
}
|
||||||
|
NavigationLink { CategoriesView() } label: {
|
||||||
|
Label("Kategorien", systemImage: "folder")
|
||||||
|
}
|
||||||
|
NavigationLink { GroupsView() } label: {
|
||||||
|
Label("Gruppen", systemImage: "square.stack.3d.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("App") {
|
||||||
|
Button {
|
||||||
|
manageShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Server verwalten", systemImage: "server.rack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Abmelden", role: .destructive) { session.logout() }
|
||||||
|
} footer: {
|
||||||
|
Text("Benutzer, Einstellungen und Sicherungen bleiben der Web-Oberfläche vorbehalten.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Verwaltung")
|
||||||
|
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gemeinsamer Bauplan
|
||||||
|
|
||||||
|
/// Ein Eintrag, wie ihn die Stammdatenliste braucht - unabhaengig davon, ob
|
||||||
|
/// dahinter ein Lagerort, eine Einheit oder ein Gebinde steckt.
|
||||||
|
struct MasterDataItem: Identifiable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
/// Eingebautes laesst sich umbenennen, aber nicht loeschen.
|
||||||
|
let isBuiltin: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste mit Anlegen, Umbenennen und Loeschen.
|
||||||
|
///
|
||||||
|
/// Alle fuenf Stammdatenbereiche sehen gleich aus und unterscheiden sich nur
|
||||||
|
/// darin, *wie* geladen und geschrieben wird. Genau das wird hier
|
||||||
|
/// hereingereicht, statt die Ansicht fuenfmal zu schreiben.
|
||||||
|
struct MasterDataListView<Editor: View>: View {
|
||||||
|
let title: String
|
||||||
|
/// Einzahl fuer Meldungen ("Lagerort").
|
||||||
|
let singular: String
|
||||||
|
let load: () async throws -> [MasterDataItem]
|
||||||
|
let delete: ((Int) async throws -> Void)?
|
||||||
|
/// Baut den Editor zum Anlegen (`nil`) oder Bearbeiten eines Eintrags.
|
||||||
|
@ViewBuilder let editor: (MasterDataItem?, @escaping () -> Void) -> Editor
|
||||||
|
|
||||||
|
@State private var items: [MasterDataItem] = []
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var editing: MasterDataItem?
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var pendingDeletion: MasterDataItem?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
ForEach(items) { item in
|
||||||
|
Button {
|
||||||
|
editing = item
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.title).foregroundStyle(.primary)
|
||||||
|
if !item.subtitle.isEmpty {
|
||||||
|
Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if item.isBuiltin {
|
||||||
|
Text("eingebaut").font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
// Eingebautes bekommt gar nicht erst einen Loeschknopf.
|
||||||
|
if delete != nil && !item.isBuiltin {
|
||||||
|
Button("Löschen", role: .destructive) { pendingDeletion = item }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.isEmpty && !busy {
|
||||||
|
Text("Noch nichts angelegt.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Hinzufügen", systemImage: "plus") { addShown = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $addShown) {
|
||||||
|
editor(nil) { Task { await reload() } }
|
||||||
|
}
|
||||||
|
.sheet(item: $editing) { item in
|
||||||
|
editor(item) { Task { await reload() } }
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
pendingDeletion.map { "„\($0.title)“ löschen?" } ?? "",
|
||||||
|
isPresented: Binding(get: { pendingDeletion != nil },
|
||||||
|
set: { if !$0 { pendingDeletion = nil } }),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
if let item = pendingDeletion { Task { await remove(item) } }
|
||||||
|
pendingDeletion = nil
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
|
||||||
|
} message: {
|
||||||
|
Text("Wird \(singular.lowercased()) noch verwendet, lehnt der Server das Löschen ab.")
|
||||||
|
}
|
||||||
|
.refreshable { await reload() }
|
||||||
|
.task { await reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
items = try await load()
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remove(_ item: MasterDataItem) async {
|
||||||
|
guard let delete else { return }
|
||||||
|
do {
|
||||||
|
try await delete(item.id)
|
||||||
|
error = nil
|
||||||
|
await reload()
|
||||||
|
} catch {
|
||||||
|
// Der Server begruendet die Ablehnung ("wird noch verwendet") -
|
||||||
|
// die Meldung unveraendert zeigen.
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kleiner Editor fuer alles, was nur einen Namen hat.
|
||||||
|
struct NameEditor: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
let label: String
|
||||||
|
let initial: String
|
||||||
|
let save: (String) async throws -> Void
|
||||||
|
let done: () -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var busy = false
|
||||||
|
|
||||||
|
init(title: String, label: String = "Name", initial: String = "",
|
||||||
|
save: @escaping (String) async throws -> Void, done: @escaping () -> Void) {
|
||||||
|
self.title = title
|
||||||
|
self.label = label
|
||||||
|
self.initial = initial
|
||||||
|
self.save = save
|
||||||
|
self.done = done
|
||||||
|
_name = State(initialValue: initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(label) {
|
||||||
|
TextField(label, text: $name)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
|
||||||
|
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
try await save(name.trimmingCharacters(in: .whitespaces))
|
||||||
|
done()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Die fuenf Bereiche
|
||||||
|
|
||||||
|
struct LocationsView: View {
|
||||||
|
var body: some View {
|
||||||
|
MasterDataListView(
|
||||||
|
title: "Lagerorte",
|
||||||
|
singular: "Der Lagerort",
|
||||||
|
load: {
|
||||||
|
try await APIClient.shared.locations().map {
|
||||||
|
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: { try await APIClient.shared.deleteLocation(id: $0) }
|
||||||
|
) { item, done in
|
||||||
|
NameEditor(
|
||||||
|
title: item == nil ? "Lagerort anlegen" : "Lagerort umbenennen",
|
||||||
|
initial: item?.title ?? "",
|
||||||
|
save: { name in
|
||||||
|
if let item {
|
||||||
|
_ = try await APIClient.shared.renameLocation(id: item.id, name: name)
|
||||||
|
} else {
|
||||||
|
_ = try await APIClient.shared.createLocation(
|
||||||
|
NewLocationRequest(name: name, parentId: nil))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done: done
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UnitsView: View {
|
||||||
|
var body: some View {
|
||||||
|
MasterDataListView(
|
||||||
|
title: "Einheiten",
|
||||||
|
singular: "Die Einheit",
|
||||||
|
load: {
|
||||||
|
try await APIClient.shared.units().map {
|
||||||
|
MasterDataItem(id: $0.id, title: $0.name,
|
||||||
|
subtitle: "\(UnitsView.artText($0.kind)) · Faktor \(formatAmount($0.factor))",
|
||||||
|
isBuiltin: $0.isBuiltin)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: { try await APIClient.shared.deleteUnit(id: $0) }
|
||||||
|
) { item, done in
|
||||||
|
if let item {
|
||||||
|
NameEditor(
|
||||||
|
title: "Einheit umbenennen",
|
||||||
|
initial: item.title,
|
||||||
|
save: { _ = try await APIClient.shared.renameUnit(id: item.id, name: $0) },
|
||||||
|
done: done
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
NewUnitEditor(done: done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func artText(_ kind: String) -> String {
|
||||||
|
switch kind {
|
||||||
|
case "count": return "Anzahl"
|
||||||
|
case "weight": return "Gewicht"
|
||||||
|
case "volume": return "Volumen"
|
||||||
|
default: return kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheiten brauchen beim Anlegen Art und Faktor - danach stehen beide fest,
|
||||||
|
/// weil sie in bereits umgerechneten Bestaenden stecken.
|
||||||
|
struct NewUnitEditor: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let done: () -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var kind = "weight"
|
||||||
|
@State private var factor = "1"
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var busy = false
|
||||||
|
|
||||||
|
private var faktorWert: Double? {
|
||||||
|
Double(factor.replacingOccurrences(of: ",", with: "."))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("z. B. Beutel", text: $name)
|
||||||
|
}
|
||||||
|
Section("Art") {
|
||||||
|
Picker("Art", selection: $kind) {
|
||||||
|
Text("Anzahl").tag("count")
|
||||||
|
Text("Gewicht").tag("weight")
|
||||||
|
Text("Volumen").tag("volume")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
TextField("1", text: $factor).keyboardType(.decimalPad)
|
||||||
|
} header: {
|
||||||
|
Text("Faktor")
|
||||||
|
} footer: {
|
||||||
|
Text("Wie viele Basiseinheiten ergibt eine Einheit? Ein Kilogramm hat den Faktor 1000, weil die Basiseinheit Gramm ist.")
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einheit anlegen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") { Task { await submit() } }
|
||||||
|
.disabled(busy || name.isEmpty || (faktorWert ?? 0) <= 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
guard let wert = faktorWert else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.createUnit(
|
||||||
|
NewUnitRequest(name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
kind: kind, factor: wert))
|
||||||
|
done()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PackageTypesView: View {
|
||||||
|
var body: some View {
|
||||||
|
MasterDataListView(
|
||||||
|
title: "Gebinde",
|
||||||
|
singular: "Das Gebinde",
|
||||||
|
load: {
|
||||||
|
try await APIClient.shared.packageTypes().map {
|
||||||
|
MasterDataItem(id: $0.id, title: $0.singular,
|
||||||
|
subtitle: "Mehrzahl: \($0.plural)", isBuiltin: $0.isBuiltin)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: { try await APIClient.shared.deletePackageType(id: $0) }
|
||||||
|
) { item, done in
|
||||||
|
PackageTypeEditor(id: item?.id,
|
||||||
|
singular: item?.title ?? "",
|
||||||
|
plural: item.map { PackageTypesView.plural(from: $0.subtitle) } ?? "",
|
||||||
|
done: done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Untertitel traegt die Mehrzahl - hier wieder heraus.
|
||||||
|
private static func plural(from subtitle: String) -> String {
|
||||||
|
subtitle.replacingOccurrences(of: "Mehrzahl: ", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gebinde haben Einzahl **und** Mehrzahl (siehe Commit "Gebinde verwalten").
|
||||||
|
struct PackageTypeEditor: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let id: Int?
|
||||||
|
let done: () -> Void
|
||||||
|
|
||||||
|
@State private var singular: String
|
||||||
|
@State private var plural: String
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var busy = false
|
||||||
|
|
||||||
|
init(id: Int?, singular: String, plural: String, done: @escaping () -> Void) {
|
||||||
|
self.id = id
|
||||||
|
self.done = done
|
||||||
|
_singular = State(initialValue: singular)
|
||||||
|
_plural = State(initialValue: plural)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Einzahl") {
|
||||||
|
TextField("z. B. Glas", text: $singular)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
TextField(singular.isEmpty ? "z. B. Gläser" : singular, text: $plural)
|
||||||
|
} header: {
|
||||||
|
Text("Mehrzahl")
|
||||||
|
} footer: {
|
||||||
|
Text("Ohne Angabe wird die Einzahl verwendet.")
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(id == nil ? "Gebinde anlegen" : "Gebinde bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") { Task { await submit() } }
|
||||||
|
.disabled(busy || singular.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
let einzahl = singular.trimmingCharacters(in: .whitespaces)
|
||||||
|
let mehrzahl = plural.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
? einzahl : plural.trimmingCharacters(in: .whitespaces)
|
||||||
|
do {
|
||||||
|
if let id {
|
||||||
|
_ = try await APIClient.shared.updatePackageType(
|
||||||
|
id: id, PackageTypeUpdateRequest(singular: einzahl, plural: mehrzahl))
|
||||||
|
} else {
|
||||||
|
_ = try await APIClient.shared.createPackageType(
|
||||||
|
NewPackageTypeRequest(singular: einzahl, plural: mehrzahl))
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CategoriesView: View {
|
||||||
|
var body: some View {
|
||||||
|
MasterDataListView(
|
||||||
|
title: "Kategorien",
|
||||||
|
singular: "Die Kategorie",
|
||||||
|
load: {
|
||||||
|
let alle = try await APIClient.shared.categories()
|
||||||
|
// Baumreihenfolge mit Einrueckung, damit die Verschachtelung
|
||||||
|
// sichtbar bleibt (wie in der Produktliste).
|
||||||
|
return CategoriesView.tree(alle).map {
|
||||||
|
MasterDataItem(id: $0.item.id,
|
||||||
|
title: String(repeating: " ", count: $0.depth) + $0.item.name,
|
||||||
|
subtitle: "", isBuiltin: false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: { try await APIClient.shared.deleteCategory(id: $0) }
|
||||||
|
) { item, done in
|
||||||
|
NameEditor(
|
||||||
|
title: item == nil ? "Kategorie anlegen" : "Kategorie umbenennen",
|
||||||
|
initial: item?.title.trimmingCharacters(in: .whitespaces) ?? "",
|
||||||
|
save: { name in
|
||||||
|
if let item {
|
||||||
|
_ = try await APIClient.shared.renameCategory(id: item.id, name: name)
|
||||||
|
} else {
|
||||||
|
_ = try await APIClient.shared.createCategory(
|
||||||
|
NewCategoryRequest(name: name, parentId: nil))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done: done
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Node {
|
||||||
|
let item: CategoryItem
|
||||||
|
let depth: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func tree(_ categories: [CategoryItem]) -> [Node] {
|
||||||
|
let ids = Set(categories.map(\.id))
|
||||||
|
var result: [Node] = []
|
||||||
|
|
||||||
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
|
result.append(Node(item: node, depth: depth))
|
||||||
|
for child in categories.filter({ $0.parentId == node.id }) {
|
||||||
|
walk(child, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||||||
|
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||||
|
walk(root, 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupsView: View {
|
||||||
|
var body: some View {
|
||||||
|
MasterDataListView(
|
||||||
|
title: "Gruppen",
|
||||||
|
singular: "Die Gruppe",
|
||||||
|
load: {
|
||||||
|
try await APIClient.shared.groups().map {
|
||||||
|
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delete: { try await APIClient.shared.deleteGroup(id: $0) }
|
||||||
|
) { item, done in
|
||||||
|
NameEditor(
|
||||||
|
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen",
|
||||||
|
initial: item?.title ?? "",
|
||||||
|
save: { name in
|
||||||
|
if let item {
|
||||||
|
_ = try await APIClient.shared.renameGroup(id: item.id, name: name)
|
||||||
|
} else {
|
||||||
|
_ = try await APIClient.shared.createGroup(
|
||||||
|
NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done: done
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ struct Unit: Codable, Identifiable, Hashable {
|
|||||||
let name: String
|
let name: String
|
||||||
let kind: String
|
let kind: String
|
||||||
let factor: Double
|
let factor: Double
|
||||||
|
/// Eingebaute Einheiten lassen sich umbenennen, aber nicht loeschen.
|
||||||
|
let isBuiltin: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, kind, factor
|
||||||
|
case isBuiltin = "is_builtin"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
||||||
@@ -404,6 +411,119 @@ struct CategoryItem: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Uebersicht und Verlauf
|
||||||
|
|
||||||
|
/// Kennzahlen der Startseite (GET /dashboard/stats).
|
||||||
|
struct DashboardStats: Codable {
|
||||||
|
let productsInStock: Int
|
||||||
|
let articleUnits: Double
|
||||||
|
let expiringSoon: Int
|
||||||
|
let expired: Int
|
||||||
|
let shoppingItems: Int
|
||||||
|
let productsTotal: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case productsInStock = "products_in_stock"
|
||||||
|
case articleUnits = "article_units"
|
||||||
|
case expiringSoon = "expiring_soon"
|
||||||
|
case expired
|
||||||
|
case shoppingItems = "shopping_items"
|
||||||
|
case productsTotal = "products_total"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Eintrag im Bewegungsverlauf (GET /movements).
|
||||||
|
struct Movement: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let productId: Int
|
||||||
|
let productName: String
|
||||||
|
/// "in" oder "out"
|
||||||
|
let type: String
|
||||||
|
/// in Basiseinheiten
|
||||||
|
let quantity: Double
|
||||||
|
let baseUnit: String
|
||||||
|
let unitUsed: String
|
||||||
|
let username: String?
|
||||||
|
let note: String?
|
||||||
|
let createdAt: String
|
||||||
|
let packageSize: Double?
|
||||||
|
let packageLabel: String?
|
||||||
|
let unitName: String
|
||||||
|
let unitFactor: Double
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, type, quantity, username, note
|
||||||
|
case productId = "product_id"
|
||||||
|
case productName = "product_name"
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
case unitUsed = "unit_used"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case packageLabel = "package_label"
|
||||||
|
case unitName = "unit_name"
|
||||||
|
case unitFactor = "unit_factor"
|
||||||
|
}
|
||||||
|
|
||||||
|
var isIncoming: Bool { type == "in" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stammdaten
|
||||||
|
|
||||||
|
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
|
||||||
|
struct PackageType: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let singular: String
|
||||||
|
let plural: String
|
||||||
|
let isBuiltin: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, singular, plural
|
||||||
|
case isBuiltin = "is_builtin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewLocationRequest: Codable {
|
||||||
|
let name: String
|
||||||
|
let parentId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case parentId = "parent_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewUnitRequest: Codable {
|
||||||
|
let name: String
|
||||||
|
let kind: String
|
||||||
|
let factor: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewPackageTypeRequest: Codable {
|
||||||
|
let singular: String
|
||||||
|
let plural: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewCategoryRequest: Codable {
|
||||||
|
let name: String
|
||||||
|
let parentId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case parentId = "parent_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Umbenennen von Stammdaten. Ein einzelnes Feld reicht, weil das Backend nur
|
||||||
|
/// mitgeschickte Schluessel auswertet.
|
||||||
|
struct RenameRequest: Codable {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PackageTypeUpdateRequest: Codable {
|
||||||
|
let singular: String
|
||||||
|
let plural: String
|
||||||
|
}
|
||||||
|
|
||||||
/// Neue Gruppe anlegen (nur fuer Administratoren).
|
/// Neue Gruppe anlegen (nur fuer Administratoren).
|
||||||
struct NewGroupRequest: Codable {
|
struct NewGroupRequest: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
|
|||||||
123
ios/Sources/OverviewView.swift
Normal file
123
ios/Sources/OverviewView.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Startseite: Kennzahlen des Servers und die letzten Bewegungen.
|
||||||
|
///
|
||||||
|
/// Bewusst ohne Diagramme - die Kennzahl selbst ist die Antwort, und jede
|
||||||
|
/// fuehrt per Tipp dorthin, wo man etwas tun kann.
|
||||||
|
struct OverviewView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
@State private var stats: DashboardStats?
|
||||||
|
@State private var letzte: [Movement] = []
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var manageShown = false
|
||||||
|
|
||||||
|
private let spalten = [GridItem(.flexible()), GridItem(.flexible())]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
LazyVGrid(columns: spalten, spacing: 12) {
|
||||||
|
NavigationLink { ProductListView() } label: {
|
||||||
|
StatTile(title: "Artikel mit Bestand",
|
||||||
|
value: stats.map { String($0.productsInStock) } ?? "–",
|
||||||
|
detail: stats.map { "von \($0.productsTotal)" } ?? "",
|
||||||
|
systemImage: "shippingbox", tint: .accentColor)
|
||||||
|
}
|
||||||
|
NavigationLink { ShoppingListView() } label: {
|
||||||
|
StatTile(title: "Einzukaufen",
|
||||||
|
value: stats.map { String($0.shoppingItems) } ?? "–",
|
||||||
|
detail: "unter Mindestbestand",
|
||||||
|
systemImage: "cart",
|
||||||
|
tint: (stats?.shoppingItems ?? 0) > 0 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
NavigationLink { ExpiringView() } label: {
|
||||||
|
StatTile(title: "Läuft bald ab",
|
||||||
|
value: stats.map { String($0.expiringSoon) } ?? "–",
|
||||||
|
detail: "Chargen",
|
||||||
|
systemImage: "clock",
|
||||||
|
tint: (stats?.expiringSoon ?? 0) > 0 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
NavigationLink { ExpiringView() } label: {
|
||||||
|
StatTile(title: "Abgelaufen",
|
||||||
|
value: stats.map { String($0.expired) } ?? "–",
|
||||||
|
detail: "Chargen",
|
||||||
|
systemImage: "exclamationmark.triangle",
|
||||||
|
tint: (stats?.expired ?? 0) > 0 ? .red : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(letzte) { movement in
|
||||||
|
MovementRow(movement: movement)
|
||||||
|
}
|
||||||
|
if letzte.isEmpty && !busy {
|
||||||
|
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
NavigationLink("Ganzen Verlauf ansehen") { HistoryView() }
|
||||||
|
} header: {
|
||||||
|
Text("Letzte Bewegungen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
ServerMenu(manageShown: $manageShown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task(id: session.activeProfileID) { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
stats = try await APIClient.shared.dashboardStats()
|
||||||
|
letzte = try await APIClient.shared.movements(limit: 10)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kennzahl als Kachel.
|
||||||
|
struct StatTile: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let detail: String
|
||||||
|
let systemImage: String
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage).font(.caption).foregroundStyle(tint)
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
Text(value).font(.title).bold().foregroundStyle(tint)
|
||||||
|
if !detail.isEmpty {
|
||||||
|
Text(detail).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,14 @@ struct ProductDetailView: View {
|
|||||||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
|
||||||
|
// gefiltert.
|
||||||
|
NavigationLink { HistoryView(productId: current.id) } label: {
|
||||||
|
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(current.name)
|
.navigationTitle(current.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ struct RootView: View {
|
|||||||
LoginView()
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server.
|
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
|
||||||
.task(id: session.isLoggedIn) {
|
// Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
|
||||||
if session.isLoggedIn { await display.load() }
|
// Server B die Formate von Server A.
|
||||||
|
.task(id: session.activeProfileID) {
|
||||||
|
if session.isLoggedIn {
|
||||||
|
await display.load()
|
||||||
|
await session.refreshMe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
||||||
.fullScreenCover(item: $router.route) { route in
|
.fullScreenCover(item: $router.route) { route in
|
||||||
@@ -35,6 +40,30 @@ extension Route: Identifiable {
|
|||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
OverviewView()
|
||||||
|
.tabItem { Label("Start", systemImage: "house") }
|
||||||
|
|
||||||
|
ScanTabView()
|
||||||
|
.tabItem { Label("Scannen", systemImage: "barcode.viewfinder") }
|
||||||
|
|
||||||
|
ListsTabView()
|
||||||
|
.tabItem { Label("Listen", systemImage: "list.bullet") }
|
||||||
|
|
||||||
|
// Stammdaten gehoeren Administratoren - dieselbe Regel wie in der
|
||||||
|
// Web-Oberflaeche (web/src/App.jsx).
|
||||||
|
if session.isAdmin {
|
||||||
|
AdminTabView()
|
||||||
|
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die beiden Kacheln, die frueher die Startseite waren.
|
||||||
|
struct ScanTabView: View {
|
||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -54,6 +83,24 @@ struct HomeView: View {
|
|||||||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||||
systemImage: "arrow.up.to.line")
|
systemImage: "arrow.up.to.line")
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Scannen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ListsTabView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
NavigationLink {
|
||||||
|
ProductListView()
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
||||||
|
systemImage: "shippingbox")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ShoppingListView()
|
ShoppingListView()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -67,25 +114,15 @@ struct HomeView: View {
|
|||||||
systemImage: "clock")
|
systemImage: "clock")
|
||||||
}
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductListView()
|
HistoryView()
|
||||||
} label: {
|
} label: {
|
||||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||||||
systemImage: "list.bullet")
|
systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Vorrania")
|
.navigationTitle("Listen")
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
|
||||||
Text("Angemeldet als \(session.username)")
|
|
||||||
Button("Abmelden", role: .destructive) { session.logout() }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "person.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
187
ios/Sources/ServerListView.swift
Normal file
187
ios/Sources/ServerListView.swift
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Wechselt den Server aus der Werkzeugleiste heraus - der schnelle Weg.
|
||||||
|
/// Die vollstaendige Verwaltung liegt in `ServerListView`.
|
||||||
|
struct ServerMenu: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Binding var manageShown: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
if session.profiles.count > 1 {
|
||||||
|
Section("Server") {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
Button {
|
||||||
|
guard profile.id != session.activeProfileID else { return }
|
||||||
|
session.switchTo(profile.id)
|
||||||
|
} label: {
|
||||||
|
// Der Haken zeigt, wo man gerade ist. Ein leerer
|
||||||
|
// Symbolname wuerde eine kaputte Lucke hinterlassen,
|
||||||
|
// deshalb zwei getrennte Beschriftungen.
|
||||||
|
if profile.id == session.activeProfileID {
|
||||||
|
Label(profile.name, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(profile.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Server verwalten…", systemImage: "server.rack") { manageShown = true }
|
||||||
|
if session.isLoggedIn {
|
||||||
|
Divider()
|
||||||
|
Text("Angemeldet als \(session.username)")
|
||||||
|
Button("Abmelden", role: .destructive) { session.logout() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "person.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerListView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var editing: ServerProfile?
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var pendingDeletion: ServerProfile?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
Button {
|
||||||
|
session.switchTo(profile.id)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
row(for: profile)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button("Löschen", role: .destructive) { pendingDeletion = profile }
|
||||||
|
Button("Bearbeiten") { editing = profile }.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.profiles.isEmpty {
|
||||||
|
Text("Noch kein Server hinterlegt.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Server")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Hinzufügen", systemImage: "plus") { addShown = true }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $addShown) {
|
||||||
|
ServerEditView(profile: nil)
|
||||||
|
}
|
||||||
|
.sheet(item: $editing) { profile in
|
||||||
|
ServerEditView(profile: profile)
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "",
|
||||||
|
isPresented: Binding(get: { pendingDeletion != nil },
|
||||||
|
set: { if !$0 { pendingDeletion = nil } }),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
if let profile = pendingDeletion { session.removeProfile(id: profile.id) }
|
||||||
|
pendingDeletion = nil
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
|
||||||
|
} message: {
|
||||||
|
Text("Die gespeicherte Anmeldung für diesen Server wird mit entfernt. Auf dem Server selbst ändert sich nichts.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(for profile: ServerProfile) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(profile.name).font(.body)
|
||||||
|
Text(profile.urlString).font(.caption).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1).truncationMode(.middle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if profile.id == session.activeProfileID {
|
||||||
|
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlegen und Bearbeiten in einem - der Unterschied ist nur, ob schon ein
|
||||||
|
/// Profil hereingereicht wurde.
|
||||||
|
struct ServerEditView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let profile: ServerProfile?
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var urlString: String
|
||||||
|
|
||||||
|
init(profile: ServerProfile?) {
|
||||||
|
self.profile = profile
|
||||||
|
_name = State(initialValue: profile?.name ?? "")
|
||||||
|
_urlString = State(initialValue: profile?.urlString ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
Session.normalize(urlString) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Der Name steht bewusst oben: Er ist das, was in der App zu
|
||||||
|
// sehen ist ("Zuhause"), die Adresse braucht man nur einmal.
|
||||||
|
Section {
|
||||||
|
TextField("z. B. Zuhause", text: $name)
|
||||||
|
} header: {
|
||||||
|
Text("Name")
|
||||||
|
} footer: {
|
||||||
|
Text("So heißt der Server in der App. Ohne Angabe wird der Rechnername verwendet – meist die IP-Adresse.")
|
||||||
|
}
|
||||||
|
Section("Adresse") {
|
||||||
|
TextField("http://192.168.1.10:8080", text: $urlString)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(profile == nil ? "Server hinzufügen" : "Server bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") { save() }.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let profile {
|
||||||
|
session.updateProfile(id: profile.id, name: name, urlString: urlString)
|
||||||
|
} else {
|
||||||
|
// Nach dem Anlegen ist der neue Server der aktive; fehlt dort eine
|
||||||
|
// Anmeldung, erscheint sie von selbst.
|
||||||
|
session.addProfile(name: name, urlString: urlString)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
72
ios/Sources/ServerProfiles.swift
Normal file
72
ios/Sources/ServerProfiles.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ein hinterlegter Server. Die App kann beliebig viele davon kennen und
|
||||||
|
/// zwischen ihnen umschalten, ohne sich jedes Mal neu anzumelden.
|
||||||
|
///
|
||||||
|
/// Das Token liegt **nicht** hier, sondern im Keychain unter einem Konto, das
|
||||||
|
/// aus der `id` gebildet wird (siehe `Session.keychainAccount`). Diese Struktur
|
||||||
|
/// landet als JSON in den UserDefaults und darf deshalb nichts Geheimes
|
||||||
|
/// enthalten.
|
||||||
|
struct ServerProfile: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var urlString: String
|
||||||
|
/// Zuletzt angemeldeter Benutzer - nur fuer die Anzeige und damit das
|
||||||
|
/// Anmeldefeld beim Wechsel schon ausgefuellt ist.
|
||||||
|
var username: String
|
||||||
|
/// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint,
|
||||||
|
/// bevor `me()` den Wert frisch bestaetigt hat.
|
||||||
|
var isAdmin: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, urlString: String,
|
||||||
|
username: String = "", isAdmin: Bool = false) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.urlString = urlString
|
||||||
|
self.username = username
|
||||||
|
self.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
var url: URL? { Session.normalize(urlString) }
|
||||||
|
|
||||||
|
/// Ohne eigenen Namen ist der Rechnername die brauchbarste Bezeichnung:
|
||||||
|
/// aus "http://192.168.1.50:8080/" wird "192.168.1.50".
|
||||||
|
static func suggestedName(for raw: String) -> String {
|
||||||
|
guard let url = Session.normalize(raw), let host = url.host, !host.isEmpty else {
|
||||||
|
return "Server"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest und schreibt die Profilliste. Bewusst getrennt von `Session`, damit
|
||||||
|
/// die Ablage fuer sich verstaendlich bleibt.
|
||||||
|
enum ProfileStore {
|
||||||
|
static let profilesKey = "server_profiles"
|
||||||
|
static let activeKey = "active_profile_id"
|
||||||
|
|
||||||
|
static func load() -> [ServerProfile] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: profilesKey),
|
||||||
|
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ profiles: [ServerProfile]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(profiles) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: profilesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadActiveID() -> UUID? {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: activeKey) else { return nil }
|
||||||
|
return UUID(uuidString: raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveActiveID(_ id: UUID?) {
|
||||||
|
if let id {
|
||||||
|
UserDefaults.standard.set(id.uuidString, forKey: activeKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: activeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,81 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
|
||||||
/// die Server-URL in den UserDefaults (nicht geheim).
|
///
|
||||||
|
/// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an
|
||||||
|
/// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp.
|
||||||
|
/// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im
|
||||||
|
/// Keychain.
|
||||||
final class Session: ObservableObject {
|
final class Session: ObservableObject {
|
||||||
static let shared = Session()
|
static let shared = Session()
|
||||||
|
|
||||||
private let urlKey = "server_url"
|
|
||||||
private let usernameKey = "username"
|
|
||||||
private let stayKey = "stay_logged_in"
|
private let stayKey = "stay_logged_in"
|
||||||
private let keychainAccount = "vorrania-token"
|
|
||||||
|
|
||||||
@Published private(set) var baseURL: URL?
|
// Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start
|
||||||
|
// nach dem Update in ein Profil ueberfuehrt und danach entfernt.
|
||||||
|
private let legacyURLKey = "server_url"
|
||||||
|
private let legacyUsernameKey = "username"
|
||||||
|
private let legacyAccount = "vorrania-token"
|
||||||
|
|
||||||
|
@Published private(set) var profiles: [ServerProfile] = []
|
||||||
|
@Published private(set) var activeProfileID: UUID?
|
||||||
@Published private(set) var token: String?
|
@Published private(set) var token: String?
|
||||||
@Published var username: String = ""
|
|
||||||
@Published var isAdmin: Bool = false
|
|
||||||
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
||||||
@Published private(set) var stayLoggedIn: Bool = true
|
@Published private(set) var stayLoggedIn: Bool = true
|
||||||
|
|
||||||
|
var activeProfile: ServerProfile? {
|
||||||
|
profiles.first { $0.id == activeProfileID }
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseURL: URL? { activeProfile?.url }
|
||||||
|
var username: String { activeProfile?.username ?? "" }
|
||||||
|
var isAdmin: Bool { activeProfile?.isAdmin ?? false }
|
||||||
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
|
||||||
baseURL = Session.normalize(stored)
|
|
||||||
}
|
|
||||||
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
||||||
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
||||||
token = Keychain.read(account: keychainAccount)
|
profiles = ProfileStore.load()
|
||||||
username = UserDefaults.standard.string(forKey: usernameKey) ?? ""
|
migrateSingleServerIfNeeded()
|
||||||
|
activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id
|
||||||
|
loadTokenForActiveProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere
|
||||||
|
/// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen.
|
||||||
|
private func migrateSingleServerIfNeeded() {
|
||||||
|
guard profiles.isEmpty,
|
||||||
|
let stored = UserDefaults.standard.string(forKey: legacyURLKey),
|
||||||
|
!stored.isEmpty
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let name = ServerProfile.suggestedName(for: stored)
|
||||||
|
let profile = ServerProfile(
|
||||||
|
name: name,
|
||||||
|
urlString: stored,
|
||||||
|
username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? ""
|
||||||
|
)
|
||||||
|
profiles = [profile]
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
ProfileStore.saveActiveID(profile.id)
|
||||||
|
|
||||||
|
// Token auf das profilbezogene Konto umziehen.
|
||||||
|
if let old = Keychain.read(account: legacyAccount) {
|
||||||
|
Keychain.write(old, account: Session.keychainAccount(for: profile.id))
|
||||||
|
Keychain.delete(account: legacyAccount)
|
||||||
|
}
|
||||||
|
UserDefaults.standard.removeObject(forKey: legacyURLKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: legacyUsernameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keychainAccount(for id: UUID) -> String {
|
||||||
|
"vorrania-token-\(id.uuidString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadTokenForActiveProfile() {
|
||||||
|
guard let id = activeProfileID else { token = nil; return }
|
||||||
|
token = Keychain.read(account: Session.keychainAccount(for: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
||||||
@@ -40,36 +88,118 @@ final class Session: ObservableObject {
|
|||||||
return URL(string: text)
|
return URL(string: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Profile
|
||||||
|
|
||||||
|
/// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der
|
||||||
|
/// Rechnername genommen.
|
||||||
|
@discardableResult
|
||||||
|
func addProfile(name: String = "", urlString: String) -> ServerProfile? {
|
||||||
|
guard Session.normalize(urlString) != nil else { return nil }
|
||||||
|
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let profile = ServerProfile(
|
||||||
|
name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title,
|
||||||
|
urlString: urlString
|
||||||
|
)
|
||||||
|
profiles.append(profile)
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
switchTo(profile.id)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) {
|
||||||
|
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
if let name {
|
||||||
|
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !title.isEmpty { profiles[index].name = title }
|
||||||
|
}
|
||||||
|
if let urlString, Session.normalize(urlString) != nil {
|
||||||
|
profiles[index].urlString = urlString
|
||||||
|
}
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben,
|
||||||
|
/// wenn der Server aus der Liste verschwindet.
|
||||||
|
func removeProfile(id: UUID) {
|
||||||
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
|
profiles.removeAll { $0.id == id }
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
|
||||||
|
if activeProfileID == id {
|
||||||
|
switchTo(profiles.first?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man
|
||||||
|
/// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil.
|
||||||
|
func switchTo(_ id: UUID?) {
|
||||||
|
activeProfileID = id
|
||||||
|
ProfileStore.saveActiveID(id)
|
||||||
|
loadTokenForActiveProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anmeldung
|
||||||
|
|
||||||
|
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil
|
||||||
|
/// oder legt das erste an, wenn die App noch keinen Server kennt.
|
||||||
func setServer(_ raw: String) {
|
func setServer(_ raw: String) {
|
||||||
guard let url = Session.normalize(raw) else { return }
|
guard Session.normalize(raw) != nil else { return }
|
||||||
baseURL = url
|
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
|
||||||
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
updateProfile(id: id, urlString: raw)
|
||||||
|
} else {
|
||||||
|
addProfile(urlString: raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||||||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||||||
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
||||||
token = newToken
|
token = newToken
|
||||||
username = name
|
|
||||||
isAdmin = admin
|
|
||||||
stayLoggedIn = persist
|
stayLoggedIn = persist
|
||||||
UserDefaults.standard.set(persist, forKey: stayKey)
|
UserDefaults.standard.set(persist, forKey: stayKey)
|
||||||
|
|
||||||
|
guard let id = activeProfileID,
|
||||||
|
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
profiles[index].isAdmin = admin
|
||||||
|
profiles[index].username = persist ? name : ""
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
|
||||||
if persist {
|
if persist {
|
||||||
Keychain.write(newToken, account: keychainAccount)
|
Keychain.write(newToken, account: Session.keychainAccount(for: id))
|
||||||
UserDefaults.standard.set(name, forKey: usernameKey)
|
|
||||||
} else {
|
} else {
|
||||||
Keychain.delete(account: keychainAccount)
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
|
||||||
func logout() {
|
func logout() {
|
||||||
token = nil
|
token = nil
|
||||||
username = ""
|
guard let id = activeProfileID else { return }
|
||||||
isAdmin = false
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
Keychain.delete(account: keychainAccount)
|
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
profiles[index].username = ""
|
||||||
|
profiles[index].isAdmin = false
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt
|
||||||
|
/// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung.
|
||||||
|
@MainActor
|
||||||
|
func refreshMe() async {
|
||||||
|
guard isLoggedIn, let id = activeProfileID else { return }
|
||||||
|
do {
|
||||||
|
let me = try await APIClient.shared.me()
|
||||||
|
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
profiles[index].username = me.username
|
||||||
|
profiles[index].isAdmin = me.role == "admin"
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
} catch APIError.unauthorized {
|
||||||
|
logout()
|
||||||
|
} catch {
|
||||||
|
// Server gerade nicht erreichbar: angemeldet bleiben, damit ein
|
||||||
|
// kurzer Netzausfall einen nicht aus der App wirft.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,19 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
||||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
||||||
|
191427830389D44062F0A622 /* OverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BDE41825692274409DDCAC7 /* OverviewView.swift */; };
|
||||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
||||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
||||||
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
||||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||||
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
||||||
|
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
|
||||||
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||||
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
||||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
||||||
@@ -30,6 +35,9 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
|
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
|
||||||
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
|
0BDE41825692274409DDCAC7 /* OverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewView.swift; sourceTree = "<group>"; };
|
||||||
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
||||||
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
||||||
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
@@ -37,6 +45,7 @@
|
|||||||
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
||||||
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
||||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
||||||
@@ -47,6 +56,7 @@
|
|||||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
||||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
||||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
||||||
|
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -61,13 +71,18 @@
|
|||||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||||
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||||
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||||
|
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
|
||||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||||
4741D0E95875919C921945CF /* RootView.swift */,
|
4741D0E95875919C921945CF /* RootView.swift */,
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
||||||
|
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
||||||
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
||||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
||||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
||||||
);
|
);
|
||||||
@@ -170,13 +185,18 @@
|
|||||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||||
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||||
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||||
|
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
|
||||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
||||||
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
||||||
|
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
||||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user