From 7f9895245cb553f5e0549cef248d1180d5e27a26 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 26 Jul 2026 08:09:00 +0200 Subject: [PATCH] Lagerort-QR + Zuordnen per Scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web: druckbare QR-Etiketten fuer Lagerorte (Locations-Seite) und Route /l/ (zeigt den Ort im Browser). Geteilter QR-Helfer (web/src/qr.jsx). iOS: neuer Ablauf "Ort zuordnen" - erst Einzelstueck-QR (…/i/) scannen, dann Lagerort-QR (…/l/); das Stueck wird dem Ort zugewiesen, danach gleich das naechste. Plus die zuvor gebaute "Nachschlagen"-Kachel. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/AssignScanView.swift | 90 ++++++++++++++++++++++++++ ios/Sources/RootView.swift | 8 +++ ios/Sources/VorraniaApp.swift | 1 + ios/Vorrania.xcodeproj/project.pbxproj | 4 ++ web/src/App.jsx | 2 + web/src/pages/LocationResolve.jsx | 38 +++++++++++ web/src/pages/Locations.jsx | 16 ++++- web/src/qr.jsx | 42 ++++++++++++ 8 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 ios/Sources/AssignScanView.swift create mode 100644 web/src/pages/LocationResolve.jsx create mode 100644 web/src/qr.jsx diff --git a/ios/Sources/AssignScanView.swift b/ios/Sources/AssignScanView.swift new file mode 100644 index 0000000..73e3ddd --- /dev/null +++ b/ios/Sources/AssignScanView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Zuordnen per Scan: erst ein Einzelstück (…/i/) scannen, dann einen +/// Lagerort (…/l/) – das Stück wird dem Ort zugewiesen. Danach gleich das +/// nächste Stück, ohne Tippen. +struct AssignScanView: View { + @Environment(\.dismiss) private var dismiss + + @State private var paused = false + @State private var torchOn = false + @State private var pending: Item? // gescanntes Stück, wartet auf Ort + @State private var status: String? + @State private var error: String? + + var body: some View { + ScrollView { + VStack(spacing: 14) { + ScannerView(onCode: { code in Task { await handle(code) } }, + isPaused: $paused, torchOn: $torchOn) + .aspectRatio(4.0 / 3.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal) + + if let pending { + VStack(spacing: 4) { + Text("Einzelstück \(pending.uid)").font(.headline) + Text("Jetzt den Lagerort-QR scannen") + .font(.caption).foregroundStyle(.secondary) + Button("Abbrechen") { self.pending = nil } + .font(.caption).padding(.top, 2) + } + .padding().frame(maxWidth: .infinity) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal) + } else { + Text("Schritt 1: Einzelstück-QR scannen") + .font(.caption).foregroundStyle(.secondary) + } + + if let status { + Text(status).font(.callout).foregroundStyle(.green) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) + } + if let error { + Text(error).font(.callout).foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) + } + } + .padding(.vertical) + } + .navigationTitle("Ort zuordnen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } + ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } + } + } + + private func handle(_ code: String) async { + // Lagerort-QR (…/l/)? + if let r = code.range(of: "/l/") { + let idStr = String(code[r.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) + if let locId = Int(idStr) { + guard let it = pending else { + error = "Erst ein Einzelstück scannen."; status = nil; return + } + do { + _ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest( + locationId: locId, shopId: it.shopId, + acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note)) + status = "\(it.uid) zugeordnet. Nächstes Stück scannen." + error = nil + pending = nil + } catch { self.error = error.localizedDescription } + return + } + } + // Einzelstück-QR (…/i/)? + if let r = code.range(of: "/i/") { + let uid = String(code[r.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased() + if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) { + pending = it; status = nil; error = nil + return + } + } + error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen." + } +} diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index d09a755..39419a9 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -30,6 +30,7 @@ struct RootView: View { case .checkin: CheckInView() case .checkout: CheckOutView() case .lookup: LookupScanView() + case .assign: AssignScanView() case .expiring: ExpiringView() .toolbar { @@ -111,6 +112,13 @@ struct ScanTabView: View { subtitle: "Ohne Barcode – Name, Kategorie, eigene Felder", systemImage: "plus") } + Button { + router.route = .assign + } label: { + ActionTile(title: "Ort zuordnen", + subtitle: "Einzelstück-QR scannen, dann Lagerort-QR", + systemImage: "mappin.and.ellipse") + } } Spacer() } diff --git a/ios/Sources/VorraniaApp.swift b/ios/Sources/VorraniaApp.swift index eb71880..08e4370 100644 --- a/ios/Sources/VorraniaApp.swift +++ b/ios/Sources/VorraniaApp.swift @@ -10,6 +10,7 @@ enum Route: String { case checkout case expiring case lookup + case assign } final class Router: ObservableObject { diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index a7f8a4e..6f982ef 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; 56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; }; + 5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; }; 6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; }; 728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; }; 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; }; @@ -64,6 +65,7 @@ 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; + 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = ""; }; 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = ""; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = ""; }; @@ -89,6 +91,7 @@ children = ( 378F1B2DE567B62169E84426 /* APIClient.swift */, 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */, + 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */, AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */, CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */, E580BA2815BB8F6341D64B47 /* ChartCards.swift */, @@ -215,6 +218,7 @@ files = ( 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */, F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */, + 5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */, A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */, 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */, FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */, diff --git a/web/src/App.jsx b/web/src/App.jsx index 06f4ac5..5a2c7ad 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -14,6 +14,7 @@ import Groups from "./pages/Groups"; import Categories from "./pages/Categories"; import Shops from "./pages/Shops"; import ItemResolve from "./pages/ItemResolve"; +import LocationResolve from "./pages/LocationResolve"; import Locations from "./pages/Locations"; import PackageTypes from "./pages/PackageTypes"; import Units from "./pages/Units"; @@ -136,6 +137,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/pages/LocationResolve.jsx b/web/src/pages/LocationResolve.jsx new file mode 100644 index 0000000..5cf3cb8 --- /dev/null +++ b/web/src/pages/LocationResolve.jsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { api } from "../api"; +import Icon from "../components/Icon"; + +/** + * Ziel eines gescannten Lagerort-QR (/l/). Im Browser zeigt es nur den + * Namen; das eigentliche Zuordnen (Stück → Ort) läuft über die App. + */ +export default function LocationResolve() { + const { id } = useParams(); + const [name, setName] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + api.listLocations() + .then((ls) => { + const l = ls.find((x) => String(x.id) === String(id)); + if (l) setName(l.name); + else setError("Lagerort nicht gefunden."); + }) + .catch((e) => setError(e.message)); + }, [id]); + + return ( +
+
+
+

Lagerort {name || `#${id}`}

+
+ {error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."} +
+
+
+ {error &&
{error}
} +
+ ); +} diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 9073631..8ec189b 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import { QrImg, printQrLabels } from "../qr"; export default function Locations() { const confirm = useConfirm(); @@ -71,6 +72,12 @@ export default function Locations() {

Lagerorte

Orte lassen sich verschachteln (z.B. Keller → Regal 2 → Fach A)
+ {locations.length > 0 && ( + + )} {error &&
{error}
} @@ -108,9 +115,12 @@ export default function Locations() { in {nameById[l.parent_id]} )} - + + + + ))} {locations.length === 0 &&
  • Noch keine Lagerorte.
  • } diff --git a/web/src/qr.jsx b/web/src/qr.jsx new file mode 100644 index 0000000..c8859e1 --- /dev/null +++ b/web/src/qr.jsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import QRCode from "qrcode"; + +/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */ +export function QrImg({ text, size = 60 }) { + const [url, setUrl] = useState(null); + useEffect(() => { + let ok = true; + QRCode.toDataURL(text, { margin: 1, width: size * 2 }) + .then((u) => { if (ok) setUrl(u); }) + .catch(() => {}); + return () => { ok = false; }; + }, [text, size]); + return url + ? QR + : ; +} + +const esc = (s) => (s || "").replace(/[<>&]/g, ""); + +/** Druckt QR-Etiketten in einem neuen Fenster. entries: [{ text, line1, line2 }]. */ +export async function printQrLabels(title, entries) { + const labels = await Promise.all(entries.map(async (e) => { + const url = await QRCode.toDataURL(e.text, { margin: 1, width: 260 }); + return `
    ${esc(e.line1)}
    ` + + `
    ${esc(e.line2)}
    `; + })); + const w = window.open("", "_blank"); + if (!w) return false; + w.document.write( + `${esc(title)}${labels.join("")} + `, + ); + w.document.close(); + return true; +}