Compare commits
2 Commits
4730d791e8
...
ff158a1e5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff158a1e5f | ||
|
|
794a81f58b |
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Location, User
|
||||
from ..schemas import LocationCreate, LocationOut
|
||||
from ..schemas import LocationCreate, LocationOut, LocationUpdate
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
@@ -29,6 +30,33 @@ def create_location(
|
||||
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)
|
||||
def delete_location(
|
||||
location_id: int,
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Group, Product, Unit, User
|
||||
from ..schemas import UnitCreate, UnitOut
|
||||
from ..schemas import UnitCreate, UnitOut, UnitUpdate
|
||||
|
||||
router = APIRouter(prefix="/units", tags=["units"])
|
||||
|
||||
@@ -33,6 +33,34 @@ def create_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)
|
||||
def delete_unit(
|
||||
unit_id: int,
|
||||
|
||||
@@ -23,6 +23,13 @@ class UnitCreate(BaseModel):
|
||||
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) ----
|
||||
class ApiTokenOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -156,6 +163,10 @@ class LocationCreate(BaseModel):
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
# ---- Gebinde (Packung, Glas, …) ----
|
||||
class PackageTypeCreate(BaseModel):
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}()
|
||||
|
||||
// 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
|
||||
|
||||
/// 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 {
|
||||
@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 password = ""
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
||||
@State private var manageShown = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -18,6 +19,15 @@ struct LoginView: View {
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.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") {
|
||||
TextField("Benutzername", text: $username)
|
||||
@@ -40,8 +50,31 @@ struct LoginView: View {
|
||||
.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 {
|
||||
|
||||
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 kind: String
|
||||
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 {
|
||||
@@ -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).
|
||||
struct NewGroupRequest: Codable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -13,9 +13,14 @@ struct RootView: View {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server.
|
||||
.task(id: session.isLoggedIn) {
|
||||
if session.isLoggedIn { await display.load() }
|
||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
|
||||
// Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
|
||||
// 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.
|
||||
.fullScreenCover(item: $router.route) { route in
|
||||
@@ -35,6 +40,30 @@ extension Route: Identifiable {
|
||||
|
||||
struct HomeView: View {
|
||||
@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
|
||||
|
||||
var body: some View {
|
||||
@@ -54,6 +83,24 @@ struct HomeView: View {
|
||||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||
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 {
|
||||
ShoppingListView()
|
||||
} label: {
|
||||
@@ -67,25 +114,15 @@ struct HomeView: View {
|
||||
systemImage: "clock")
|
||||
}
|
||||
NavigationLink {
|
||||
ProductListView()
|
||||
HistoryView()
|
||||
} label: {
|
||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
||||
systemImage: "list.bullet")
|
||||
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||||
systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Vorrania")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Text("Angemeldet als \(session.username)")
|
||||
Button("Abmelden", role: .destructive) { session.logout() }
|
||||
} label: {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Listen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Security
|
||||
|
||||
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
||||
/// die Server-URL in den UserDefaults (nicht geheim).
|
||||
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
|
||||
///
|
||||
/// 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 {
|
||||
static let shared = Session()
|
||||
|
||||
private let urlKey = "server_url"
|
||||
private let usernameKey = "username"
|
||||
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 var username: String = ""
|
||||
@Published var isAdmin: Bool = false
|
||||
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
||||
@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 }
|
||||
|
||||
private init() {
|
||||
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
||||
baseURL = Session.normalize(stored)
|
||||
}
|
||||
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
||||
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
||||
token = Keychain.read(account: keychainAccount)
|
||||
username = UserDefaults.standard.string(forKey: usernameKey) ?? ""
|
||||
profiles = ProfileStore.load()
|
||||
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
|
||||
@@ -40,36 +88,118 @@ final class Session: ObservableObject {
|
||||
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) {
|
||||
guard let url = Session.normalize(raw) else { return }
|
||||
baseURL = url
|
||||
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
||||
guard Session.normalize(raw) != nil else { return }
|
||||
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
|
||||
updateProfile(id: id, urlString: raw)
|
||||
} else {
|
||||
addProfile(urlString: raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||||
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
||||
token = newToken
|
||||
username = name
|
||||
isAdmin = admin
|
||||
stayLoggedIn = persist
|
||||
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 {
|
||||
Keychain.write(newToken, account: keychainAccount)
|
||||
UserDefaults.standard.set(name, forKey: usernameKey)
|
||||
Keychain.write(newToken, account: Session.keychainAccount(for: id))
|
||||
} else {
|
||||
Keychain.delete(account: keychainAccount)
|
||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
|
||||
func logout() {
|
||||
token = nil
|
||||
username = ""
|
||||
isAdmin = false
|
||||
Keychain.delete(account: keychainAccount)
|
||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
||||
guard let id = activeProfileID else { return }
|
||||
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||||
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 */
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.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 */; };
|
||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.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 */; };
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||
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 */; };
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
||||
@@ -30,6 +35,9 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -37,6 +45,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -47,6 +56,7 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -61,13 +71,18 @@
|
||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||
0BDE41825692274409DDCAC7 /* OverviewView.swift */,
|
||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||
4741D0E95875919C921945CF /* RootView.swift */,
|
||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
||||
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
||||
);
|
||||
@@ -170,13 +185,18 @@
|
||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||
191427830389D44062F0A622 /* OverviewView.swift in Sources */,
|
||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
||||
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user