1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 08:29:36 +01:00

feat(scanner): Mobile Scanner App mit echten Produktionsaufträgen

Scanner-App  Tauri v2 Android App mit
Login, Barcode-Scanner, Consume/Move/Book-Flows und Aufgabenliste.

TasksPage lädt echte RELEASED/IN_PROGRESS Produktionsaufträge via API,
Consume-Flow nutzt den konkreten Auftrag für korrekte Rezept-Skalierung
statt blind den ersten IN_PROGRESS-Auftrag zu nehmen.

Backend: FindStockByBatchId Use Case + REST-Endpoint für Stock-Lookup
per Chargennummer.
This commit is contained in:
Sebastian Frick 2026-03-20 13:58:54 +01:00
parent 72979c9537
commit bf09e3b747
93 changed files with 8977 additions and 60 deletions

View file

@ -0,0 +1,28 @@
package de.effigenix.application.inventory;
import de.effigenix.domain.inventory.Stock;
import de.effigenix.domain.inventory.StockError;
import de.effigenix.domain.inventory.StockRepository;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.util.List;
public class FindStockByBatchId {
private final StockRepository stockRepository;
public FindStockByBatchId(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
public Result<StockError, List<Stock>> execute(String batchId) {
if (batchId == null || batchId.isBlank()) {
return Result.failure(new StockError.InvalidBatchReference("Batch ID must not be blank"));
}
return switch (stockRepository.findAllByBatchId(batchId)) {
case Result.Failure(var err) -> Result.failure(new StockError.RepositoryFailure(err.message()));
case Result.Success(var stocks) -> Result.success(stocks);
};
}
}

View file

@ -14,6 +14,7 @@ import de.effigenix.application.inventory.BookProductionOutput;
import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.ConfirmReservation;
import de.effigenix.application.inventory.RecordStockMovement; import de.effigenix.application.inventory.RecordStockMovement;
import de.effigenix.application.inventory.ActivateStorageLocation; import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.FindStockByBatchId;
import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CheckStockExpiry; import de.effigenix.application.inventory.CheckStockExpiry;
@ -99,6 +100,11 @@ public class InventoryUseCaseConfiguration {
return new ListStocks(stockRepository); return new ListStocks(stockRepository);
} }
@Bean
public FindStockByBatchId findStockByBatchId(StockRepository stockRepository) {
return new FindStockByBatchId(stockRepository);
}
@Bean @Bean
public AddStockBatch addStockBatch(StockRepository stockRepository, UnitOfWork unitOfWork) { public AddStockBatch addStockBatch(StockRepository stockRepository, UnitOfWork unitOfWork) {
return new AddStockBatch(stockRepository, unitOfWork); return new AddStockBatch(stockRepository, unitOfWork);

View file

@ -264,6 +264,24 @@ public class JdbcStockRepository implements StockRepository {
} }
} }
@Override
public Result<RepositoryError, List<Stock>> findAllByBatchId(String batchId) {
try {
var stocks = jdbc.sql("""
SELECT DISTINCT s.* FROM stocks s
JOIN stock_batches b ON b.stock_id = s.id
WHERE b.batch_id = :batchId
""")
.param("batchId", batchId)
.query(this::mapStockRow)
.list();
return Result.success(loadChildrenForAll(stocks));
} catch (Exception e) {
logger.trace("Database error in findAllByBatchId", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override @Override
public Result<RepositoryError, Void> save(Stock stock) { public Result<RepositoryError, Void> save(Stock stock) {
try { try {

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.inventory.web.controller; package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.FindStockByBatchId;
import de.effigenix.application.inventory.AddStockBatch; import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch; import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.ConfirmReservation; import de.effigenix.application.inventory.ConfirmReservation;
@ -63,6 +64,7 @@ public class StockController {
private final GetStock getStock; private final GetStock getStock;
private final ListStocks listStocks; private final ListStocks listStocks;
private final ListStocksBelowMinimum listStocksBelowMinimum; private final ListStocksBelowMinimum listStocksBelowMinimum;
private final FindStockByBatchId findStockByBatchId;
private final AddStockBatch addStockBatch; private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch; private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch; private final BlockStockBatch blockStockBatch;
@ -72,7 +74,7 @@ public class StockController {
private final ConfirmReservation confirmReservation; private final ConfirmReservation confirmReservation;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks, public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum, ListStocksBelowMinimum listStocksBelowMinimum, FindStockByBatchId findStockByBatchId,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch, AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch, BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
ReserveStock reserveStock, ReleaseReservation releaseReservation, ReserveStock reserveStock, ReleaseReservation releaseReservation,
@ -82,6 +84,7 @@ public class StockController {
this.getStock = getStock; this.getStock = getStock;
this.listStocks = listStocks; this.listStocks = listStocks;
this.listStocksBelowMinimum = listStocksBelowMinimum; this.listStocksBelowMinimum = listStocksBelowMinimum;
this.findStockByBatchId = findStockByBatchId;
this.addStockBatch = addStockBatch; this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch; this.removeStockBatch = removeStockBatch;
this.blockStockBatch = blockStockBatch; this.blockStockBatch = blockStockBatch;
@ -109,6 +112,20 @@ public class StockController {
}; };
} }
@GetMapping("/by-batch/{batchId}")
@PreAuthorize("hasAuthority('STOCK_READ')")
public ResponseEntity<List<StockResponse>> findByBatchId(@PathVariable String batchId) {
return switch (findStockByBatchId.execute(batchId)) {
case Result.Failure(var err) -> throw new StockDomainErrorException(err);
case Result.Success(var stocks) -> {
var responses = stocks.stream()
.map(StockResponse::from)
.toList();
yield ResponseEntity.ok(responses);
}
};
}
// NOTE: Must be declared before /{id} to avoid Spring matching "below-minimum" as path variable // NOTE: Must be declared before /{id} to avoid Spring matching "below-minimum" as path variable
@GetMapping("/below-minimum") @GetMapping("/below-minimum")
@PreAuthorize("hasAuthority('STOCK_READ')") @PreAuthorize("hasAuthority('STOCK_READ')")

View file

@ -79,7 +79,7 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins); configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);

View file

@ -54,7 +54,7 @@ logging:
# CORS Configuration # CORS Configuration
effigenix: effigenix:
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:1420,tauri://localhost}
inventory: inventory:
expiry-check-cron: "0 0 6 * * *" expiry-check-cron: "0 0 6 * * *"

View file

@ -153,5 +153,6 @@ class ListStocksBelowMinimumTest {
@Override public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { return Result.success(List.of()); } @Override public Result<RepositoryError, List<Stock>> findAllWithExpiryRelevantBatches(LocalDate referenceDate) { return Result.success(List.of()); }
@Override public Result<RepositoryError, List<Stock>> findAllByBatchId(String batchId) { return Result.success(List.of()); } @Override public Result<RepositoryError, List<Stock>> findAllByBatchId(String batchId) { return Result.success(List.of()); }
@Override public Result<RepositoryError, Void> save(Stock stock) { return Result.success(null); } @Override public Result<RepositoryError, Void> save(Stock stock) { return Result.success(null); }
@Override public Result<RepositoryError, List<Stock>> findAllByBatchId(String batchId) { return Result.success(List.of()); }
} }
} }

View file

@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
overlays = [ (import rust-overlay) ]; overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs {
inherit system overlays;
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" ]; extensions = [ "rust-src" "rust-analyzer" ];
@ -25,6 +31,23 @@
"x86_64-linux-android" "x86_64-linux-android"
]; ];
}; };
# ─── Android SDK ──────────────────────────
androidComposition = pkgs.androidenv.composeAndroidPackages {
cmdLineToolsVersion = "8.0";
platformToolsVersion = "35.0.1";
buildToolsVersions = [ "34.0.0" "35.0.0" ];
platformVersions = [ "34" "36" ];
abiVersions = [ "x86_64" ];
includeNDK = true;
ndkVersions = [ "27.0.12077973" ];
includeSystemImages = true;
systemImageTypes = [ "google_apis_playstore" ];
includeEmulator = true;
useGoogleAPIs = true;
};
androidSdk = androidComposition.androidsdk;
androidHome = "${androidSdk}/libexec/android-sdk";
in in
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@ -52,6 +75,13 @@
harfbuzz harfbuzz
atk atk
# ─── Backend ─────────────────────────
jdk21
# ─── Android ───────────────────────────
androidSdk
jdk17
# ─── Tools ───────────────────────────── # ─── Tools ─────────────────────────────
just just
jq jq
@ -68,13 +98,36 @@
openssl openssl
]); ]);
ANDROID_HOME = androidHome;
ANDROID_SDK_ROOT = androidHome;
ANDROID_NDK_ROOT = "${androidHome}/ndk/27.0.12077973";
JAVA_HOME = "${pkgs.jdk21}";
# NixOS: Gradle's aapt2 von Maven ist dynamisch gelinkt und läuft nicht.
# Wir zeigen auf das aapt2 aus den Nix-bereitgestellten build-tools.
GRADLE_OPTS = "-Dandroid.aapt2FromMavenOverride=${androidHome}/build-tools/35.0.0/aapt2";
shellHook = '' shellHook = ''
echo "Node $(node --version)" echo "Node $(node --version)"
echo "pnpm $(pnpm --version)" echo "pnpm $(pnpm --version)"
echo "rustc $(rustc --version)" echo "rustc $(rustc --version)"
echo "cargo $(cargo --version)" echo "cargo $(cargo --version)"
echo "Java $(java --version 2>&1 | head -1)"
echo ""
echo "ANDROID_HOME=$ANDROID_HOME"
echo "" echo ""
echo "just --list für alle Befehle" echo "just --list für alle Befehle"
# Tauri mobile dev: Ports 1420 (Vite) + 1421 (HMR) in der NixOS-Firewall öffnen
if [[ "''${EFFIGENIX_OPEN_FW:-}" == "1" ]]; then
echo ""
echo "Opening firewall ports 1420/1421 for Tauri mobile dev..."
sudo iptables -C nixos-fw -p tcp --dport 1420 -j ACCEPT 2>/dev/null \
|| sudo iptables -I nixos-fw 3 -p tcp --dport 1420 -j ACCEPT
sudo iptables -C nixos-fw -p tcp --dport 1421 -j ACCEPT 2>/dev/null \
|| sudo iptables -I nixos-fw 3 -p tcp --dport 1421 -j ACCEPT
echo "Done."
fi
''; '';
}; };
}); });

View file

@ -2,16 +2,17 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#c06b3a" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>Effigenix Scanner</title> <title>Effigenix Scanner</title>
</head> </head>
<body class="bg-warm-50 text-warm-900 font-sans antialiased"> <body class="bg-warm-50 text-warm-800 font-sans antialiased">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View file

@ -12,22 +12,24 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"react": "^18.2.0", "@effigenix/api-client": "workspace:*",
"react-dom": "^18.2.0", "@effigenix/config": "workspace:*",
"@effigenix/types": "workspace:*",
"@effigenix/ui": "workspace:*",
"@effigenix/validation": "workspace:*",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-barcode-scanner": "^2.0.0", "@tauri-apps/plugin-barcode-scanner": "^2.0.0",
"@effigenix/ui": "workspace:*", "@tauri-apps/plugin-http": "^2.5.7",
"@effigenix/api-client": "workspace:*", "lucide-react": "^0.577.0",
"@effigenix/types": "workspace:*", "react": "^18.2.0",
"@effigenix/validation": "workspace:*", "react-dom": "^18.2.0"
"@effigenix/config": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^6.0.0" "vite": "^6.0.0"

View file

@ -300,6 +300,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.44"
@ -334,10 +340,57 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [ dependencies = [
"percent-encoding",
"time", "time",
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@ -361,7 +414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@ -374,7 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@ -505,6 +558,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@ -626,6 +685,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dom_query" name = "dom_query"
version = "0.27.0" version = "0.27.0"
@ -686,6 +754,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-barcode-scanner", "tauri-plugin-barcode-scanner",
"tauri-plugin-http",
] ]
[[package]] [[package]]
@ -708,6 +777,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -1037,8 +1115,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1048,9 +1128,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1214,6 +1296,25 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@ -1324,6 +1425,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@ -1335,6 +1437,23 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@ -1353,9 +1472,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@ -1721,6 +1842,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@ -1736,6 +1863,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@ -2454,6 +2587,22 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@ -2463,6 +2612,61 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -2509,6 +2713,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@ -2529,6 +2743,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@ -2547,6 +2771,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@ -2640,6 +2873,49 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store 0.22.1",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.2" version = "0.13.2"
@ -2674,6 +2950,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@ -2689,12 +2979,53 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -2903,6 +3234,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.18.0" version = "3.18.0"
@ -3141,6 +3484,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@ -3194,6 +3543,27 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@ -3215,7 +3585,7 @@ checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dispatch2", "dispatch2",
@ -3292,7 +3662,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@ -3407,6 +3777,52 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
dependencies = [
"bytes",
"cookie_store 0.21.1",
"data-url",
"http",
"regex",
"reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"tokio",
"url",
"urlpattern",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.1" version = "2.10.1"
@ -3609,6 +4025,21 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@ -3620,9 +4051,31 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@ -3904,6 +4357,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -4150,6 +4609,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.3" version = "0.2.3"
@ -4206,6 +4675,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@ -4391,6 +4869,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@ -4436,6 +4925,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@ -4924,6 +5422,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View file

@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-http = "2"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2" tauri-plugin-barcode-scanner = "2"

View file

@ -3,6 +3,19 @@
"description": "Default capabilities for the scanner app", "description": "Default capabilities for the scanner app",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default" "core:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"barcode-scanner:allow-check-permissions",
"barcode-scanner:allow-request-permissions",
"barcode-scanner:allow-open-app-settings",
{
"identifier": "http:default",
"allow": [
{ "url": "http://192.168.0.166:8080/**" },
{ "url": "http://192.168.0.*:8080/**" },
{ "url": "https://api.effigenix.de" }
]
}
] ]
} }

View file

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

View file

@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle

View file

@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties

View file

@ -0,0 +1,70 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "de.effigenix.scanner"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "de.effigenix.scanner"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")

View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.effigenix_scanner"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,11 @@
package de.effigenix.scanner
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
}
}

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.effigenix_scanner" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,4 @@
<resources>
<string name="app_name">Effigenix Scanner</string>
<string name="main_activity_title">Effigenix Scanner</string>
</resources>

View file

@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.effigenix_scanner" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View file

@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}

View file

@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}

View file

@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """pnpm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}

View file

@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}

View file

@ -0,0 +1,26 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
# Use Nix-provided aapt2 instead of Maven-downloaded one (NixOS compatibility)
android.aapt2FromMavenOverride=/nix/store/n6f18hhmk8c1mf9d2p4ks6ljw3nnzaqy-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2

View file

@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

View file

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View file

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the scanner app","local":true,"windows":["main"],"permissions":["core:default"]}} {"default":{"identifier":"default","description":"Default capabilities for the scanner app","local":true,"windows":["main"],"permissions":["core:default","barcode-scanner:allow-scan","barcode-scanner:allow-cancel","barcode-scanner:allow-check-permissions","barcode-scanner:allow-request-permissions","barcode-scanner:allow-open-app-settings",{"identifier":"http:default","allow":[{"url":"http://192.168.0.166:8080/**"},{"url":"http://192.168.0.*:8080/**"},{"url":"https://api.effigenix.de"}]}]}}

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,24 @@
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri::Builder::default(); let builder = tauri::Builder::default()
.plugin(tauri_plugin_http::init());
#[cfg(mobile)] #[cfg(mobile)]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
builder builder
.setup(|app| {
// Desktop: Zoom-Faktor erhöhen, damit die Mobile-UI
// auf einem 1× Bildschirm nicht winzig aussieht.
#[cfg(desktop)]
{
let webview = app.get_webview_window("main").unwrap();
let _ = webview.set_zoom(1.5);
}
Ok(())
})
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View file

@ -14,9 +14,10 @@
"windows": [ "windows": [
{ {
"title": "Effigenix Scanner", "title": "Effigenix Scanner",
"width": 400, "width": 645,
"height": 700, "height": 1305,
"resizable": true "resizable": true,
"zoomHotkeysEnabled": true
} }
], ],
"security": { "security": {

View file

@ -1,29 +1,66 @@
import { Button, Card } from '@effigenix/ui'; import { NavigationProvider, useNavigation, type Page } from './navigation';
import { FlowStateProvider } from './flow-state';
import { ApiProvider } from './api';
import { LoginPage } from './pages/LoginPage';
import { HomePage } from './pages/HomePage';
import { ScannerPage } from './pages/ScannerPage';
import { ManualEntryPage } from './pages/ManualEntryPage';
import { BatchDetailPage } from './pages/BatchDetailPage';
import { MoveFlowPage } from './pages/MoveFlowPage';
import { MoveConfirmPage } from './pages/MoveConfirmPage';
import { ConsumeFlowPage } from './pages/ConsumeFlowPage';
import { ConsumeQtyPage } from './pages/ConsumeQtyPage';
import { ConsumeConfirmPage } from './pages/ConsumeConfirmPage';
import { BookFlowPage } from './pages/BookFlowPage';
import { BookConfirmPage } from './pages/BookConfirmPage';
import { TasksPage } from './pages/TasksPage';
import { HistoryPage } from './pages/HistoryPage';
const pages: Record<Page, () => JSX.Element> = {
login: () => <LoginPage />,
home: () => <HomePage />,
scan: () => <ScannerPage />,
'manual-entry': () => <ManualEntryPage />,
'batch-detail': () => <BatchDetailPage />,
'move-flow': () => <MoveFlowPage />,
'move-confirm': () => <MoveConfirmPage />,
'consume-flow': () => <ConsumeFlowPage />,
'consume-qty': () => <ConsumeQtyPage />,
'consume-confirm': () => <ConsumeConfirmPage />,
'book-flow': () => <BookFlowPage />,
'book-confirm': () => <BookConfirmPage />,
tasks: () => <TasksPage />,
history: () => <HistoryPage />,
};
function Router() {
const { page } = useNavigation();
const render = pages[page];
return render ? render() : <LoginPage />;
}
function StatusBar() {
return (
<div
className="sticky top-0 z-50 bg-warm-50"
style={{ height: 'env(safe-area-inset-top, 0px)' }}
/>
);
}
export function App() { export function App() {
return ( return (
<div className="min-h-screen p-6"> <ApiProvider>
<div className="mx-auto max-w-md space-y-6"> <NavigationProvider>
<h1 className="text-2xl font-bold text-brand-700">Effigenix Scanner</h1> <FlowStateProvider>
<div className="h-dvh flex flex-col bg-warm-50">
<Card> <StatusBar />
<h2 className="mb-4 text-lg font-semibold">Chargen-Scanner</h2> <div className="flex-1 min-h-0 flex flex-col">
<p className="mb-4 text-sm text-warm-600"> <Router />
QR-Code oder Barcode scannen, um Chargen-Details anzuzeigen. </div>
</p> </div>
<Button </FlowStateProvider>
variant="primary" </NavigationProvider>
size="lg" </ApiProvider>
className="w-full"
onClick={() => {
// TODO: Barcode-Scanner Integration
console.log('Scan gestartet');
}}
>
Scan starten
</Button>
</Card>
</div>
</div>
); );
} }

View file

@ -0,0 +1,98 @@
import { createContext, useContext, useMemo, useState, useCallback, type ReactNode } from 'react';
import { createEffigenixClient, type EffigenixClient, type TokenProvider } from '@effigenix/api-client';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
/**
* Axios adapter that delegates to Tauri's HTTP plugin.
* Bypasses CORS restrictions on mobile webviews.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tauriAdapter = async (config: any) => {
const url = new URL(config.url || '', config.baseURL);
const headers: Record<string, string> = {};
if (config.headers) {
const raw = typeof config.headers.toJSON === 'function' ? config.headers.toJSON(false) : config.headers;
for (const [key, value] of Object.entries(raw)) {
if (value != null) headers[key] = String(value);
}
}
const response = await tauriFetch(url.toString(), {
method: (config.method || 'GET').toUpperCase(),
headers,
body: config.data != null
? (typeof config.data === 'string' ? config.data : JSON.stringify(config.data))
: undefined,
});
let data: unknown;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
return {
data,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
config,
};
};
interface ApiState {
client: EffigenixClient;
login: (username: string, password: string) => Promise<void>;
isAuthenticated: boolean;
}
const ApiContext = createContext<ApiState | null>(null);
export function ApiProvider({ children }: { children: ReactNode }) {
const [tokens, setTokens] = useState<{ access: string; refresh: string } | null>(null);
const tokenProvider = useMemo<TokenProvider>(() => ({
getAccessToken: async () => tokens?.access ?? null,
getRefreshToken: async () => tokens?.refresh ?? null,
saveTokens: async (accessToken, refreshToken) => {
setTokens({ access: accessToken, refresh: refreshToken });
},
clearTokens: async () => setTokens(null),
}), [tokens]);
const client = useMemo(() => createEffigenixClient(tokenProvider, {
baseUrl: API_BASE,
adapter: tauriAdapter,
}), [tokenProvider]);
const login = useCallback(async (username: string, password: string) => {
const res = await tauriFetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
const data = await res.json();
setTokens({ access: data.accessToken, refresh: data.refreshToken });
}, []);
return (
<ApiContext.Provider value={{ client, login, isAuthenticated: tokens !== null }}>
{children}
</ApiContext.Provider>
);
}
export function useApi() {
const ctx = useContext(ApiContext);
if (!ctx) throw new Error('useApi must be used within ApiProvider');
return ctx;
}

View file

@ -0,0 +1,68 @@
import {
Home,
ClipboardList,
ScanLine,
History,
Settings,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@effigenix/ui';
import { useNavigation, type Page } from '../navigation';
interface NavItem {
icon: LucideIcon;
label: string;
page: Page;
}
const items: NavItem[] = [
{ icon: Home, label: 'Start', page: 'home' },
{ icon: ClipboardList, label: 'Aufgaben', page: 'tasks' },
// scan button goes here (rendered separately)
{ icon: History, label: 'Verlauf', page: 'history' },
{ icon: Settings, label: 'Mehr', page: 'home' },
];
export function BottomNav() {
const { page, navigate, setFlowType } = useNavigation();
function handleScan() {
setFlowType('info');
navigate('scan');
}
return (
<div className="sticky bottom-0 bg-white border-t border-warm-200 px-4 pb-[env(safe-area-inset-bottom)] pt-1 flex items-end justify-around">
{items.slice(0, 2).map((item) => (
<NavTab key={item.page} item={item} active={page === item.page} onPress={() => navigate(item.page)} />
))}
<button
onClick={handleScan}
className="w-14 h-14 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 text-white flex items-center justify-center shadow-brand-lg -mt-5 border-3 border-warm-50 active:scale-[0.92] transition-transform"
>
<ScanLine className="w-6 h-6" />
</button>
{items.slice(2).map((item) => (
<NavTab key={item.label} item={item} active={page === item.page} onPress={() => navigate(item.page)} />
))}
</div>
);
}
function NavTab({ item, active, onPress }: { item: NavItem; active: boolean; onPress: () => void }) {
const Icon = item.icon;
return (
<button
onClick={onPress}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 py-1.5 text-2xs font-medium transition-colors',
active ? 'text-brand-600' : 'text-warm-400'
)}
>
<Icon className="w-5 h-5" />
<span>{item.label}</span>
</button>
);
}

View file

@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import { cn } from '@effigenix/ui';
interface PageActionsProps {
children: ReactNode;
className?: string;
}
export function PageActions({ children, className }: PageActionsProps) {
return (
<div className={cn('px-5 py-8 space-y-2.5 shrink-0', className)}>
{children}
</div>
);
}

View file

@ -0,0 +1,30 @@
import { ArrowLeft } from 'lucide-react';
import { useNavigation } from '../navigation';
import { type ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: ReactNode;
right?: ReactNode;
onBack?: () => void;
}
export function PageHeader({ title, subtitle, right, onBack }: PageHeaderProps) {
const { goBack } = useNavigation();
return (
<div className="px-5 pt-6 pb-3 flex items-center gap-3">
<button
onClick={onBack ?? goBack}
className="w-9 h-9 rounded-full bg-warm-100 flex items-center justify-center shrink-0 active:bg-warm-200 transition-colors"
>
<ArrowLeft className="w-4.5 h-4.5 text-warm-600" />
</button>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-warm-800">{title}</h1>
{subtitle && <p className="text-xs text-warm-500">{subtitle}</p>}
</div>
{right}
</div>
);
}

View file

@ -0,0 +1,56 @@
import { Check } from 'lucide-react';
import { cn } from '@effigenix/ui';
export interface Step {
label: string;
state: 'done' | 'active' | 'pending';
}
interface StepIndicatorProps {
steps: Step[];
}
export function StepIndicator({ steps }: StepIndicatorProps) {
return (
<div className="px-8 mb-2">
<div className="flex items-center gap-0 mb-1">
{steps.map((step, i) => (
<div key={i} className="contents">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all',
step.state === 'done' && 'bg-success-100 text-success-700',
step.state === 'active' && 'bg-brand-600 text-white shadow-brand',
step.state === 'pending' && 'bg-warm-200 text-warm-500'
)}
>
{step.state === 'done' ? <Check className="w-3.5 h-3.5" /> : i + 1}
</div>
{i < steps.length - 1 && (
<div
className={cn(
'flex-1 h-0.5 transition-colors',
step.state === 'done' ? 'bg-success-300' : 'bg-warm-200'
)}
/>
)}
</div>
))}
</div>
<div className="flex justify-between text-2xs font-semibold uppercase px-0.5">
{steps.map((step, i) => (
<span
key={i}
className={cn(
step.state === 'done' && 'text-success-600',
step.state === 'active' && 'text-brand-600',
step.state === 'pending' && 'text-warm-500'
)}
>
{step.label}
</span>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { CheckCircle2 } from 'lucide-react';
import { type ReactNode } from 'react';
interface SuccessScreenProps {
title: string;
subtitle: string;
children?: ReactNode;
actions: ReactNode;
}
export function SuccessScreen({ title, subtitle, children, actions }: SuccessScreenProps) {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-6 animate-fade-in">
<div className="text-center">
<div className="w-20 h-20 rounded-full bg-success-100 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 className="w-10 h-10 text-success-500" />
</div>
<h1 className="text-xl font-bold text-warm-800 mb-1">{title}</h1>
<p className="text-warm-500 text-sm mb-6">{subtitle}</p>
{children && <div className="mb-6">{children}</div>}
<div className="space-y-2.5 max-w-xs mx-auto">{actions}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
import { Card } from '@effigenix/ui';
interface SummaryRow {
label: string;
value: string;
mono?: boolean;
highlight?: 'success' | 'danger';
}
interface SummaryCardProps {
rows: SummaryRow[];
}
export function SummaryCard({ rows }: SummaryCardProps) {
return (
<Card className="p-4 text-left mx-auto max-w-xs">
<div className="space-y-2 text-sm">
{rows.map((row) => (
<div key={row.label} className="flex justify-between">
<span className="text-warm-500">{row.label}</span>
<span
className={[
row.mono && 'font-mono',
row.highlight === 'success' && 'font-semibold text-success-600',
row.highlight === 'danger' && 'font-semibold text-danger-600',
!row.highlight && 'font-medium',
]
.filter(Boolean)
.join(' ')}
>
{row.value}
</span>
</div>
))}
</div>
</Card>
);
}

View file

@ -0,0 +1,76 @@
import { createContext, useContext, useCallback, useState, type ReactNode } from 'react';
export interface ConsumeItem {
name: string;
batch: string;
location: string;
quantity: number;
unit: string;
required: number | null;
requiredUnit: string | null;
}
export interface ScaledIngredient {
articleId: string;
quantity: number;
uom: string;
}
export interface ProductionOrderContext {
orderId: string;
recipeId: string;
batchNumber: string | null;
articleName: string;
plannedQuantity: number;
plannedQuantityUnit: string;
}
interface FlowState {
consumeItems: ConsumeItem[];
consumeScanIndex: number;
scaledIngredients: ScaledIngredient[];
productionOrder: ProductionOrderContext | null;
addConsumeItem: (item: ConsumeItem) => void;
nextConsumeScan: () => void;
resetConsume: () => void;
setScaledIngredients: (ingredients: ScaledIngredient[]) => void;
setProductionOrder: (order: ProductionOrderContext) => void;
}
const FlowStateContext = createContext<FlowState | null>(null);
export function FlowStateProvider({ children }: { children: ReactNode }) {
const [consumeItems, setConsumeItems] = useState<ConsumeItem[]>([]);
const [consumeScanIndex, setConsumeScanIndex] = useState(0);
const [scaledIngredients, setScaledIngredients] = useState<ScaledIngredient[]>([]);
const [productionOrder, setProductionOrder] = useState<ProductionOrderContext | null>(null);
const addConsumeItem = useCallback((item: ConsumeItem) => {
setConsumeItems((prev) => [...prev, item]);
}, []);
const nextConsumeScan = useCallback(() => {
setConsumeScanIndex((i) => i + 1);
}, []);
const resetConsume = useCallback(() => {
setConsumeItems([]);
setConsumeScanIndex(0);
setScaledIngredients([]);
setProductionOrder(null);
}, []);
return (
<FlowStateContext.Provider
value={{ consumeItems, consumeScanIndex, scaledIngredients, productionOrder, addConsumeItem, nextConsumeScan, resetConsume, setScaledIngredients, setProductionOrder }}
>
{children}
</FlowStateContext.Provider>
);
}
export function useFlowState() {
const ctx = useContext(FlowStateContext);
if (!ctx) throw new Error('useFlowState must be used within FlowStateProvider');
return ctx;
}

View file

@ -1,4 +1,79 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@effigenix/ui/theme.css'; @import '@effigenix/ui/theme.css';
@source '../../packages/ui/src/**/*.{ts,tsx}'; @source '../../../packages/ui/src/**/*.{ts,tsx}';
/*
* Mobile Typography Scale
*
* Überschreibt die Tailwind-Defaults für Touch-optimierte Lesbarkeit.
* Gilt für die gesamte Scanner-App inkl. shared UI-Komponenten.
*
* | Klasse | Default | Mobile | Verwendung |
* |-----------|---------|--------|-----------------------------------|
* | text-2xs | | 11px | Timestamps, Mikro-Labels, % |
* | text-xs | 12px | 13px | Captions, Subtitles |
* | text-sm | 14px | 15px | Body, Listentext, Badges |
* | text-base | 16px | 16px | Card-Titel, Inputs |
* | text-lg | 18px | 20px | Seitenüberschriften |
* | text-xl | 20px | 24px | Große Überschriften |
* | text-2xl | 24px | 28px | Hero / Login-Titel |
*/
@theme {
--text-2xs: 0.6875rem;
--text-2xs--line-height: 1rem;
--text-xs: 0.8125rem;
--text-xs--line-height: 1.125rem;
--text-sm: 0.9375rem;
--text-sm--line-height: 1.375rem;
--text-base: 1rem;
--text-base--line-height: 1.5rem;
--text-lg: 1.25rem;
--text-lg--line-height: 1.625rem;
--text-xl: 1.5rem;
--text-xl--line-height: 1.75rem;
--text-2xl: 1.75rem;
--text-2xl--line-height: 2rem;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scan-line {
0% { top: 10%; }
50% { top: 85%; }
100% { top: 10%; }
}
@utility animate-fade-in {
animation: fade-in 0.2s ease;
}
@utility animate-scan-line {
animation: scan-line 2.5s ease-in-out infinite;
}
/* Mobile defaults */
html, body {
background-color: #faf9f7;
}
body {
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
input, textarea, select {
user-select: text;
-webkit-user-select: text;
}

View file

@ -0,0 +1,111 @@
export interface MockBatch {
id: string;
article: string;
articleCode: string;
category: string;
quantity: number;
unit: string;
location: string;
bestBefore: string;
status: 'planned' | 'in-production' | 'completed';
}
export interface MockActivity {
type: 'move' | 'consume' | 'production-start' | 'production-complete' | 'goods-receipt';
batchId: string;
description: string;
detail: string;
time: string;
}
export interface MockMaterial {
name: string;
batch: string;
location: string;
available: number;
unit: string;
recipeQty: number;
}
export interface MockLocation {
name: string;
temperature: string;
icon: 'cold' | 'frozen' | 'scan';
}
export const BATCH: MockBatch = {
id: 'P-2026-03-19-003',
article: 'Wiener Würstchen',
articleCode: 'ART-001',
category: 'Wurstwaren',
quantity: 118,
unit: 'kg',
location: 'Kühlhaus 1',
bestBefore: '29.03.2026',
status: 'completed',
};
export const ACTIVITIES: MockActivity[] = [
{
type: 'move',
batchId: 'P-003',
description: 'P-2026-03-19-003 → Kühlhaus 1',
detail: 'Umlagerung · 118 kg · vor 23 min',
time: '11:05',
},
{
type: 'consume',
batchId: 'P-003',
description: '54 kg Schweinefleisch S1 → P-003',
detail: 'Verbrauch · vor 1 Std',
time: '10:00',
},
{
type: 'goods-receipt',
batchId: 'WE-001',
description: 'WE-2026-03-18-001 → Kühlhaus 1',
detail: 'Wareneingang · 500 kg · vor 3 Std',
time: '08:30',
},
];
export const MATERIALS: MockMaterial[] = [
{ name: 'Schweinefleisch S1', batch: 'WE-2026-03-18-001', location: 'Kühlhaus 1', available: 145, unit: 'kg', recipeQty: 32 },
{ name: 'Schweinefett (Speck)', batch: 'WE-2026-03-17-003', location: 'Kühlhaus 1', available: 80, unit: 'kg', recipeQty: 20 },
{ name: 'Leberkäse-Gewürz', batch: 'WE-2026-03-10-014', location: 'Trockenlager', available: 8, unit: 'kg', recipeQty: 1.4 },
];
export const LOCATIONS: MockLocation[] = [
{ name: 'Kühlhaus 1', temperature: '24 °C', icon: 'cold' },
{ name: 'Kühlhaus 2', temperature: '02 °C', icon: 'cold' },
{ name: 'Tiefkühler A', temperature: '-18 bis -22 °C', icon: 'frozen' },
];
export const HISTORY_ENTRIES: Array<{ time: string; entries: MockActivity[] }> = [
{
time: '11:05',
entries: [
{ type: 'move', batchId: 'P-003', description: 'Umlagerung', detail: 'P-003 · 118 kg → KH 2', time: '11:05' },
],
},
{
time: '10:45',
entries: [
{ type: 'production-complete', batchId: 'P-003', description: 'Produktion abgeschlossen', detail: 'P-003 · Wiener · 118 kg', time: '10:45' },
],
},
{
time: '09:15',
entries: [
{ type: 'consume', batchId: 'P-003', description: 'Verbrauch', detail: '54 kg Schweinefleisch → P-003', time: '09:15' },
{ type: 'consume', batchId: 'P-003', description: 'Verbrauch', detail: '24 kg Speck → P-003', time: '09:15' },
{ type: 'consume', batchId: 'P-003', description: 'Verbrauch', detail: '2.2 kg Nitritpökelsalz → P-003', time: '09:15' },
],
},
{
time: '07:30',
entries: [
{ type: 'production-start', batchId: 'P-003', description: 'Produktion gestartet', detail: 'P-003 · Wiener Würstchen', time: '07:30' },
],
},
];

View file

@ -0,0 +1,77 @@
import { createContext, useContext, useCallback, useState, type ReactNode } from 'react';
export type Page =
| 'login'
| 'home'
| 'scan'
| 'manual-entry'
| 'batch-detail'
| 'move-flow'
| 'move-confirm'
| 'consume-flow'
| 'consume-qty'
| 'consume-confirm'
| 'book-flow'
| 'book-confirm'
| 'tasks'
| 'history';
export type FlowType = 'move' | 'consume' | 'book' | 'info';
interface NavEntry {
page: Page;
params: Record<string, unknown>;
}
interface NavigationState {
page: Page;
params: Record<string, unknown>;
flowType: FlowType;
navigate: (page: Page, params?: Record<string, unknown>) => void;
goBack: () => void;
setFlowType: (flow: FlowType) => void;
resetTo: (page: Page) => void;
}
const NavigationContext = createContext<NavigationState | null>(null);
export function NavigationProvider({ children }: { children: ReactNode }) {
const [history, setHistory] = useState<NavEntry[]>([{ page: 'login', params: {} }]);
const [flowType, setFlowType] = useState<FlowType>('info');
const current = history[history.length - 1];
const navigate = useCallback((page: Page, params: Record<string, unknown> = {}) => {
setHistory((h) => [...h, { page, params }]);
}, []);
const goBack = useCallback(() => {
setHistory((h) => (h.length > 1 ? h.slice(0, -1) : h));
}, []);
const resetTo = useCallback((page: Page) => {
setHistory([{ page, params: {} }]);
}, []);
return (
<NavigationContext.Provider
value={{
page: current.page,
params: current.params,
flowType,
navigate,
goBack,
setFlowType,
resetTo,
}}
>
{children}
</NavigationContext.Provider>
);
}
export function useNavigation() {
const ctx = useContext(NavigationContext);
if (!ctx) throw new Error('useNavigation must be used within NavigationProvider');
return ctx;
}

View file

@ -0,0 +1,117 @@
import { Beef, Check, FlaskConical, Calendar, ArrowRightLeft, PackageMinus } from 'lucide-react';
import { Card, Badge, Button } from '@effigenix/ui';
import { PageHeader } from '../components/PageHeader';
import { useNavigation } from '../navigation';
import { BATCH } from '../mock-data';
export function BatchDetailPage() {
const { navigate, setFlowType } = useNavigation();
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<PageHeader
title="Chargendetail"
subtitle={BATCH.id}
right={<Badge variant="success">Abgeschlossen</Badge>}
/>
<div className="px-5 space-y-4 flex-1 pb-4">
{/* Product Info */}
<Card className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center">
<Beef className="w-5 h-5 text-brand-500" />
</div>
<div>
<div className="font-semibold text-warm-800">{BATCH.article}</div>
<div className="text-xs text-warm-500">
{BATCH.articleCode} · {BATCH.category}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<StatBox label="Menge" value={`${BATCH.quantity} ${BATCH.unit}`} />
<StatBox label="MHD" value="29.03." />
<StatBox label="Lagerort" value="KH 1" />
</div>
</Card>
{/* Status Timeline */}
<Card className="p-4">
<h3 className="text-xs font-bold text-warm-500 uppercase tracking-wider mb-3">Status</h3>
<div className="space-y-3">
<TimelineEntry icon={Check} color="success" title="Produktion abgeschlossen" detail="19.03. 10:32 · Lisa Meier" />
<TimelineEntry icon={Check} color="success" title="Eingelagert: Kühlhaus 1" detail="19.03. 10:45 · Stefan Koch" />
<TimelineEntry icon={FlaskConical} color="info" title="Produktion gestartet" detail="19.03. 07:30 · Lisa Meier" />
<TimelineEntry icon={Calendar} color="warm" title="Geplant" detail="18.03. 14:00 · Max Huber" />
</div>
</Card>
{/* Actions */}
<div className="space-y-2.5">
<Button
variant="secondary"
className="w-full"
onClick={() => {
setFlowType('move');
navigate('move-flow');
}}
>
<ArrowRightLeft className="w-4 h-4 text-info-600" />
Umlagern
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => {
setFlowType('consume');
navigate('consume-flow');
}}
>
<PackageMinus className="w-4 h-4 text-warning-600" />
Als Verbrauch buchen
</Button>
</div>
</div>
</div>
);
}
function StatBox({ label, value }: { label: string; value: string }) {
return (
<div className="text-center p-2 bg-warm-50 rounded-lg">
<div className="text-2xs text-warm-500 font-semibold uppercase">{label}</div>
<div className="text-sm font-bold text-warm-800 mt-0.5">{value}</div>
</div>
);
}
const colorClasses = {
success: { bg: 'bg-success-100', text: 'text-success-600' },
info: { bg: 'bg-info-100', text: 'text-info-600' },
warm: { bg: 'bg-warm-100', text: 'text-warm-500' },
};
function TimelineEntry({
icon: Icon,
color,
title,
detail,
}: {
icon: typeof Check;
color: keyof typeof colorClasses;
title: string;
detail: string;
}) {
return (
<div className="flex items-center gap-3">
<div className={`w-7 h-7 rounded-full ${colorClasses[color].bg} flex items-center justify-center`}>
<Icon className={`w-3.5 h-3.5 ${colorClasses[color].text}`} />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-warm-800">{title}</div>
<div className="text-xs text-warm-500">{detail}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { ScanLine, Home } from 'lucide-react';
import { Button } from '@effigenix/ui';
import { SuccessScreen } from '../components/SuccessScreen';
import { SummaryCard } from '../components/SummaryCard';
import { useNavigation } from '../navigation';
export function BookConfirmPage() {
const { navigate, setFlowType } = useNavigation();
return (
<SuccessScreen
title="Charge abgeschlossen"
subtitle="P-2026-03-19-004 → Kühlhaus 1"
actions={
<>
<Button
className="w-full"
onClick={() => {
setFlowType('book');
navigate('scan');
}}
>
<ScanLine className="w-4 h-4" />
Nächste Charge
</Button>
<Button variant="secondary" className="w-full" onClick={() => navigate('home')}>
<Home className="w-4 h-4" />
Zurück zum Start
</Button>
</>
}
>
<SummaryCard
rows={[
{ label: 'Charge', value: 'P-2026-03-19-004', mono: true },
{ label: 'Artikel', value: 'Leberkäse Bayerisch' },
{ label: 'Ist-Menge', value: '78 kg', mono: true },
{ label: 'Ausschuss', value: '2.1 kg', mono: true },
{ label: 'Ausbeute', value: '97.5%', highlight: 'success' },
{ label: 'Eingelagert', value: 'Kühlhaus 1' },
{ label: 'MHD', value: '26.03.2026', mono: true },
]}
/>
</SuccessScreen>
);
}

View file

@ -0,0 +1,102 @@
import { FlaskConical, PackageCheck } from 'lucide-react';
import { Card, Badge, Button, Input } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { PageHeader } from '../components/PageHeader';
import { useNavigation } from '../navigation';
import { useFlowState } from '../flow-state';
import { useState } from 'react';
export function BookFlowPage() {
const { navigate, goBack } = useNavigation();
const { productionOrder } = useFlowState();
const [actualQty, setActualQty] = useState('78');
const [waste, setWaste] = useState('2.1');
const plannedQty = productionOrder?.plannedQuantity ?? 0;
const plannedUnit = productionOrder?.plannedQuantityUnit ?? 'kg';
const target = parseFloat(actualQty) || 0;
const yieldPct = plannedQty > 0 ? ((target / plannedQty) * 100).toFixed(1) : '0';
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<PageHeader title="Charge abschließen" />
<div className="px-5 space-y-4 flex-1">
{/* Batch card */}
<Card className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1">
<div className="font-semibold text-warm-800">{productionOrder?.batchNumber ?? ''}</div>
<div className="text-xs text-warm-500">
{productionOrder?.articleName ?? ''} · Soll: {plannedQty} {plannedUnit}
</div>
</div>
<Badge variant="info">In Produktion</Badge>
</div>
<div className="text-xs text-warm-500">3 Verbräuche erfasst</div>
</Card>
{/* Actual quantity */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Ist-Menge ({plannedUnit}) *</label>
<Input
type="number"
className="font-mono text-lg text-center"
value={actualQty}
onChange={(e) => setActualQty(e.target.value)}
/>
<div className="flex items-center justify-between mt-1.5 text-xs text-warm-500">
<span>Soll: {plannedQty} {plannedUnit}</span>
<span>
Ausbeute: <span className="font-semibold text-success-600">{yieldPct}%</span>
</span>
</div>
</div>
{/* Waste */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Ausschuss (kg)</label>
<Input
type="number"
className="font-mono text-lg text-center"
value={waste}
onChange={(e) => setWaste(e.target.value)}
/>
</div>
{/* Target location */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Ziel-Lagerort</label>
<select className="w-full rounded-xl border border-warm-300 bg-white px-3 py-2 text-sm text-warm-900 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 appearance-none bg-[url('data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%2216%22%20height=%2216%22%20viewBox=%220%200%2024%2024%22%20fill=%22none%22%20stroke=%22%236d6459%22%20stroke-width=%222%22%3E%3Cpath%20d=%22m6%209%206%206%206-6%22/%3E%3C/svg%3E')] bg-no-repeat bg-[position:right_0.75rem_center] pr-10">
<option>Kühlhaus 1 (24 °C)</option>
<option>Kühlhaus 2 (02 °C)</option>
<option>Regal Ladentheke</option>
</select>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Bemerkung</label>
<textarea
className="w-full rounded-xl border border-warm-300 bg-white px-3 py-2 text-sm text-warm-900 placeholder:text-warm-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
rows={2}
placeholder="Optional…"
/>
</div>
</div>
<PageActions>
<Button variant="success" size="lg" className="w-full" onClick={() => navigate('book-confirm')}>
<PackageCheck className="w-5 h-5" />
Charge abschließen &amp; einlagern
</Button>
<Button variant="ghost" className="w-full text-warm-500" onClick={goBack}>
Abbrechen
</Button>
</PageActions>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { ScanLine, PackageCheck, Home } from 'lucide-react';
import { Button } from '@effigenix/ui';
import { SuccessScreen } from '../components/SuccessScreen';
import { useNavigation } from '../navigation';
import { useFlowState } from '../flow-state';
export function ConsumeConfirmPage() {
const { navigate, setFlowType } = useNavigation();
const { consumeItems, resetConsume, productionOrder } = useFlowState();
const count = consumeItems.length;
return (
<SuccessScreen
title="Verbrauch gebucht"
subtitle={`${count} Materialien → ${productionOrder?.batchNumber ?? ''}`}
actions={
<>
<Button
className="w-full"
onClick={() => {
resetConsume();
setFlowType('consume');
navigate('scan');
}}
>
<ScanLine className="w-4 h-4" />
Weitere Materialien scannen
</Button>
<Button variant="success" className="w-full" onClick={() => navigate('book-flow')}>
<PackageCheck className="w-4 h-4" />
Charge abschließen
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => {
resetConsume();
navigate('home');
}}
>
<Home className="w-4 h-4" />
Zurück zum Start
</Button>
</>
}
/>
);
}

View file

@ -0,0 +1,119 @@
import { FlaskConical, ScanLine, Check, PackageSearch } from 'lucide-react';
import { Card, Button } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { PageHeader } from '../components/PageHeader';
import { StepIndicator } from '../components/StepIndicator';
import { useNavigation } from '../navigation';
import { useFlowState } from '../flow-state';
export function ConsumeFlowPage() {
const { navigate, setFlowType } = useNavigation();
const { consumeItems, productionOrder } = useFlowState();
const hasItems = consumeItems.length > 0;
if (!productionOrder) {
navigate('tasks');
return null;
}
function startScan() {
setFlowType('consume');
navigate('scan');
}
return (
<div className="flex-1 flex flex-col animate-fade-in">
<PageHeader
title="Verbrauch erfassen"
subtitle={
<span>
Produktionscharge:{' '}
<span className="font-mono font-medium text-brand-600">{productionOrder.batchNumber ?? ''}</span>
</span>
}
/>
<StepIndicator
steps={[
{ label: 'Prod.-Charge', state: 'done' },
{ label: 'Materialien', state: hasItems ? 'done' : 'active' },
{ label: 'Bestätigen', state: hasItems ? 'active' : 'pending' },
]}
/>
{/* Production batch */}
<div className="px-5 mb-2">
<Card className="p-2.5 flex items-center gap-3 border-brand-200 bg-brand-50">
<div className="w-8 h-8 rounded-lg bg-brand-100 flex items-center justify-center">
<FlaskConical className="w-4 h-4 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-warm-800">
{productionOrder.batchNumber ?? ''} · {productionOrder.articleName}
</div>
<div className="text-xs text-warm-500">
Soll: {productionOrder.plannedQuantity} {productionOrder.plannedQuantityUnit} · In Produktion
</div>
</div>
</Card>
</div>
{/* Scanned materials scrollable area */}
<div className="px-5 flex-1 min-h-0 overflow-y-auto space-y-2">
<h2 className="text-xs font-bold text-warm-500 uppercase tracking-wider mb-2">
Gescannte Materialien ({consumeItems.length})
</h2>
{consumeItems.length > 0 ? (
<div className="space-y-2">
{consumeItems.map((item, i) => (
<Card key={i} className="p-2.5 flex items-center gap-3 animate-fade-in">
<div className="w-2 h-10 rounded-full bg-brand-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-warm-800">{item.name}</div>
<div className="text-xs text-warm-500">
{item.batch} · {item.location}
</div>
</div>
<div className="text-right shrink-0">
<div className="font-mono font-semibold text-sm text-warm-800">
{item.quantity} {item.unit}
</div>
{item.required != null && (
<div className="text-2xs text-warm-400">
von {item.required} {item.requiredUnit ?? item.unit}
</div>
)}
</div>
</Card>
))}
</div>
) : (
<Card className="p-5 text-center">
<div className="inline-flex items-center justify-center w-11 h-11 rounded-xl bg-warm-100 mb-2">
<PackageSearch className="w-5 h-5 text-warm-400" />
</div>
<p className="text-sm text-warm-500 mb-0.5">Noch keine Materialien erfasst</p>
<p className="text-xs text-warm-400">
Chargen-Etiketten der Eingangsmaterialien scannen.
</p>
</Card>
)}
<Button variant="secondary" size='lg' className="w-full" onClick={startScan}>
<ScanLine className="w-4 h-4" />
{consumeItems.length === 0 ? 'Erste Zutat scannen' : 'Nächste Zutat scannen'}
</Button>
</div>
<PageActions>
{hasItems && (
<Button variant="primary" size='lg' className="w-full" onClick={() => navigate('consume-confirm')}>
<Check className="w-5 h-5" />
{consumeItems.length} Verbrauch{consumeItems.length !== 1 ? 'e' : ''} buchen
</Button>
)}
</PageActions>
</div>
);
}

View file

@ -0,0 +1,254 @@
import { CheckCircle2, Check, X, AlertCircle, Loader2 } from 'lucide-react';
import { Card, Button, Input } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { useNavigation } from '../navigation';
import { useFlowState, type ScaledIngredient } from '../flow-state';
import { useApi } from '../api';
import { useState, useRef, useEffect } from 'react';
import type { StockDTO, ArticleDTO, StorageLocationDTO } from '@effigenix/types';
interface BatchLookup {
stock: StockDTO;
batch: StockDTO['batches'][number];
article: ArticleDTO;
location: StorageLocationDTO;
scaledIngredient: ScaledIngredient | null;
}
type LookupState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ok'; data: BatchLookup };
export function ConsumeQtyPage() {
const { navigate, params } = useNavigation();
const { addConsumeItem, nextConsumeScan, scaledIngredients, setScaledIngredients, productionOrder } = useFlowState();
const { client } = useApi();
const inputRef = useRef<HTMLInputElement>(null);
const [quantity, setQuantity] = useState('');
const [lookup, setLookup] = useState<LookupState>({ status: 'loading' });
const batchId = params.batchId as string | undefined;
useEffect(() => {
if (!batchId) {
setLookup({ status: 'error', message: 'Keine Chargennummer übergeben' });
return;
}
let cancelled = false;
(async () => {
try {
// Fetch stock + scaled ingredients in parallel
const [stocks, ingredients] = await Promise.all([
client.stocks.findByBatchId(batchId),
scaledIngredients.length > 0
? Promise.resolve(scaledIngredients)
: loadScaledIngredients(),
]);
if (cancelled) return;
if (stocks.length === 0) {
setLookup({ status: 'error', message: `Charge "${batchId}" nicht im Bestand gefunden` });
return;
}
const stock = stocks[0];
const batch = stock.batches.find((b) => b.batchId === batchId);
if (!batch) {
setLookup({ status: 'error', message: `Charge "${batchId}" nicht im Bestand gefunden` });
return;
}
const [article, location] = await Promise.all([
client.articles.getById(stock.articleId),
client.storageLocations.getById(stock.storageLocationId),
]);
if (cancelled) return;
const scaledIngredient = ingredients.find((i) => i.articleId === stock.articleId) ?? null;
setLookup({ status: 'ok', data: { stock, batch, article, location, scaledIngredient } });
setTimeout(() => inputRef.current?.focus(), 300);
} catch (e) {
if (!cancelled) {
setLookup({ status: 'error', message: 'Fehler beim Laden der Chargendaten' });
}
}
})();
return () => { cancelled = true; };
}, [batchId]);
async function loadScaledIngredients(): Promise<ScaledIngredient[]> {
try {
if (!productionOrder?.recipeId) return [];
const recipe = await client.recipes.getById(productionOrder.recipeId);
const outputQty = parseFloat(recipe.outputQuantity) || 1;
const factor = productionOrder.plannedQuantity / outputQty;
const result = recipe.ingredients.map((i) => ({
articleId: i.articleId,
quantity: Math.round(parseFloat(i.quantity) * factor * 100) / 100,
uom: i.uom,
}));
setScaledIngredients(result);
return result;
} catch {
return [];
}
}
function confirm() {
if (lookup.status !== 'ok') return;
const { batch, article, location, scaledIngredient } = lookup.data;
const qty = parseFloat(quantity) || batch.quantityAmount || 0;
addConsumeItem({
name: article.name,
batch: batch.batchId ?? '',
location: location.name,
quantity: qty,
unit: batch.quantityUnit ?? 'kg',
required: scaledIngredient?.quantity ?? null,
requiredUnit: scaledIngredient?.uom ?? null,
});
nextConsumeScan();
navigate('consume-flow');
}
if (lookup.status === 'loading') {
return (
<div className="flex-1 flex items-center justify-center animate-fade-in">
<div className="text-center">
<Loader2 className="w-8 h-8 text-brand-500 mx-auto mb-3 animate-spin" />
<p className="text-sm text-warm-500">Charge wird gesucht</p>
</div>
</div>
);
}
if (lookup.status === 'error') {
return (
<div className="flex-1 flex flex-col animate-fade-in">
<div className="px-5 pt-4 pb-2 flex items-center gap-3 shrink-0">
<button
onClick={() => navigate('consume-flow')}
className="w-9 h-9 rounded-full bg-warm-100 flex items-center justify-center shrink-0 active:bg-warm-200"
>
<X className="w-4.5 h-4.5 text-warm-600" />
</button>
<h1 className="text-lg font-bold text-warm-800">Charge nicht gefunden</h1>
</div>
<div className="flex-1 flex items-center justify-center px-5">
<Card className="p-5 text-center w-full border-danger-200 bg-danger-50">
<AlertCircle className="w-8 h-8 text-danger-500 mx-auto mb-2" />
<p className="text-sm text-warm-700">{lookup.message}</p>
{batchId && (
<p className="text-xs text-warm-500 font-mono mt-1">{batchId}</p>
)}
</Card>
</div>
<PageActions>
<Button variant="secondary" className="w-full" onClick={() => navigate('consume-flow')}>
Zurück
</Button>
</PageActions>
</div>
);
}
const { batch, article, location, scaledIngredient } = lookup.data;
return (
<div className="flex-1 flex flex-col animate-fade-in">
<div className="px-5 pt-4 pb-2 flex items-center gap-3 shrink-0">
<button
onClick={() => navigate('consume-flow')}
className="w-9 h-9 rounded-full bg-warm-100 flex items-center justify-center shrink-0 active:bg-warm-200"
>
<X className="w-4.5 h-4.5 text-warm-600" />
</button>
<h1 className="text-lg font-bold text-warm-800">Menge erfassen</h1>
</div>
<div className="px-5 flex-1 min-h-0 flex flex-col">
{/* Scanned material info */}
<Card className="p-3 mb-4 border-success-200 bg-success-50 shrink-0">
<div className="flex items-center gap-2 mb-1.5">
<CheckCircle2 className="w-4 h-4 text-success-600" />
<span className="text-xs font-semibold text-success-700 uppercase tracking-wider">
Charge erkannt
</span>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-10 rounded-full bg-brand-400 shrink-0" />
<div className="flex-1">
<div className="text-sm font-semibold text-warm-800">{article.name}</div>
<div className="text-xs text-warm-500 font-mono">{batch.batchId}</div>
<div className="text-xs text-warm-500">
{location.name} · Verfügbar:{' '}
<span className="font-semibold">
{batch.quantityAmount} {batch.quantityUnit}
</span>
</div>
</div>
</div>
</Card>
{/* Quantity input */}
<div className="flex-1 flex flex-col items-center justify-center">
<label className="text-sm font-medium text-warm-600 mb-2">Verbrauchte Menge</label>
<div className="flex items-end gap-2 mb-2">
<Input
ref={inputRef}
type="number"
step="0.1"
className="font-mono text-center text-4xl font-bold py-2 w-40 border-2"
placeholder="0"
value={quantity}
onKeyUp={(e) => e.key === 'Enter' && inputRef.current?.blur()}
onChange={(e) => setQuantity(e.target.value)}
/>
<span className="text-lg text-warm-500 font-medium pb-2">{batch.quantityUnit}</span>
</div>
<div className="flex items-center gap-2.5 mb-4">
{[5, 10, 20, 25].map((val) => (
<Button
key={val}
variant="secondary"
size="sm"
className="px-3.5"
onClick={() => setQuantity(String(val))}
>
{val}
</Button>
))}
</div>
{scaledIngredient ? (
<p className="text-xs text-warm-400 text-center">
Soll:{' '}
<span className="font-semibold text-brand-600">
{scaledIngredient.quantity} {scaledIngredient.uom}
</span>
</p>
) : (
<p className="text-xs text-warm-400 text-center">
Verfügbar:{' '}
<span className="font-semibold text-warm-600">
{batch.quantityAmount} {batch.quantityUnit}
</span>
</p>
)}
</div>
</div>
<PageActions>
<Button size="lg" className="w-full" onClick={confirm}>
<Check className="w-5 h-5" />
Menge übernehmen
</Button>
</PageActions>
</div>
);
}

View file

@ -0,0 +1,77 @@
import {
ArrowRightLeft,
PackageCheck,
PackageMinus,
PlayCircle,
type LucideIcon,
} from 'lucide-react';
import { Card, Button } from '@effigenix/ui';
import { BottomNav } from '../components/BottomNav';
import { HISTORY_ENTRIES, type MockActivity } from '../mock-data';
import { useState } from 'react';
const iconMap: Record<MockActivity['type'], { icon: LucideIcon; bg: string; color: string }> = {
move: { icon: ArrowRightLeft, bg: 'bg-info-50', color: 'text-info-600' },
consume: { icon: PackageMinus, bg: 'bg-warning-50', color: 'text-warning-600' },
'production-complete': { icon: PackageCheck, bg: 'bg-success-50', color: 'text-success-600' },
'production-start': { icon: PlayCircle, bg: 'bg-info-50', color: 'text-info-600' },
'goods-receipt': { icon: ArrowRightLeft, bg: 'bg-info-50', color: 'text-info-600' },
};
type TimeFilter = 'today' | 'yesterday' | 'week';
export function HistoryPage() {
const [filter, setFilter] = useState<TimeFilter>('today');
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<div className="px-5 pt-6 pb-4">
<h1 className="text-lg font-bold text-warm-800">Verlauf</h1>
<p className="text-xs text-warm-500">Alle Buchungen heute</p>
</div>
<div className="px-5 mb-3">
<div className="flex gap-2">
{(['today', 'yesterday', 'week'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? 'primary' : 'secondary'}
size="sm"
className="flex-1"
onClick={() => setFilter(f)}
>
{f === 'today' ? 'Heute' : f === 'yesterday' ? 'Gestern' : 'Woche'}
</Button>
))}
</div>
</div>
<div className="px-5 flex-1 space-y-2">
{HISTORY_ENTRIES.map((group) => (
<div key={group.time}>
<div className="text-2xs font-bold text-warm-400 uppercase tracking-wider mt-3 first:mt-0 mb-1.5">
{group.time}
</div>
{group.entries.map((entry, i) => {
const mapping = iconMap[entry.type];
const Icon = mapping.icon;
return (
<Card key={i} className="p-3 flex items-center gap-3 mb-2">
<div className={`w-8 h-8 rounded-lg ${mapping.bg} flex items-center justify-center`}>
<Icon className={`w-4 h-4 ${mapping.color}`} />
</div>
<div className="flex-1">
<div className="text-sm font-medium text-warm-800">{entry.description}</div>
<div className="text-xs text-warm-500">{entry.detail}</div>
</div>
</Card>
);
})}
</div>
))}
</div>
<BottomNav />
</div>
);
}

View file

@ -0,0 +1,140 @@
import {
Radio,
ArrowRightLeft,
PackageMinus,
PackageCheck,
Search,
CheckCircle2,
ChevronRight,
type LucideIcon,
} from 'lucide-react';
import { Card } from '@effigenix/ui';
import { useNavigation } from '../navigation';
import { BottomNav } from '../components/BottomNav';
import { ACTIVITIES, type MockActivity } from '../mock-data';
export function HomePage() {
const { navigate, setFlowType } = useNavigation();
function startFlow(flow: 'move' | 'consume' | 'book' | 'info') {
setFlowType(flow);
navigate('scan');
}
return (
<div className="min-h-screen flex flex-col animate-fade-in">
{/* Header */}
<div className="px-5 pt-6 pb-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold text-warm-800">Hallo, Lisa</h1>
<p className="text-xs text-warm-500">Mi, 19. März 2026 · Hauptbetrieb</p>
</div>
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-bold text-sm">
LM
</div>
</div>
</div>
{/* Scanner Status */}
<div className="mx-5 mb-4">
<Card className="p-3 flex items-center gap-3 border-brand-200 bg-brand-50">
<div className="w-9 h-9 rounded-lg bg-brand-100 flex items-center justify-center shrink-0">
<Radio className="w-4.5 h-4.5 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-brand-800">Scanner verbunden</div>
<div className="text-xs text-brand-600">Zebra DS3678 · Bluetooth</div>
</div>
<div className="w-2.5 h-2.5 rounded-full bg-success-400 shrink-0" />
</Card>
</div>
{/* Quick Actions */}
<div className="px-5 mb-5">
<h2 className="text-xs font-bold text-warm-500 uppercase tracking-wider mb-3">
Schnellaktionen
</h2>
<div className="grid grid-cols-2 gap-3">
<ActionCard icon={ArrowRightLeft} label="Umlagerung" sublabel="Charge → Lagerort" color="info" onPress={() => startFlow('move')} />
<ActionCard icon={PackageMinus} label="Verbrauch" sublabel="Material → Produktion" color="warning" onPress={() => startFlow('consume')} />
<ActionCard icon={PackageCheck} label="Produktion buchen" sublabel="Charge abschließen" color="success" onPress={() => startFlow('book')} />
<ActionCard icon={Search} label="Charge prüfen" sublabel="Info & Historie" color="warm" onPress={() => startFlow('info')} />
</div>
</div>
{/* Recent Activity */}
<div className="px-5 flex-1">
<h2 className="text-xs font-bold text-warm-500 uppercase tracking-wider mb-3">
Letzte Buchungen
</h2>
<div className="space-y-2.5">
{ACTIVITIES.map((activity, i) => (
<ActivityRow key={i} activity={activity} onPress={() => navigate('batch-detail')} />
))}
</div>
</div>
<BottomNav />
</div>
);
}
const colorMap = {
info: { bg: 'bg-info-50', icon: 'text-info-600' },
warning: { bg: 'bg-warning-50', icon: 'text-warning-600' },
success: { bg: 'bg-success-50', icon: 'text-success-600' },
warm: { bg: 'bg-warm-100', icon: 'text-warm-600' },
};
function ActionCard({
icon: Icon,
label,
sublabel,
color,
onPress,
}: {
icon: LucideIcon;
label: string;
sublabel: string;
color: keyof typeof colorMap;
onPress: () => void;
}) {
return (
<Card
className="p-4 flex flex-col items-center gap-2 text-center cursor-pointer active:scale-[0.97] active:bg-warm-50 transition"
onClick={onPress}
>
<div className={`w-11 h-11 rounded-xl ${colorMap[color].bg} flex items-center justify-center`}>
<Icon className={`w-5 h-5 ${colorMap[color].icon}`} />
</div>
<span className="text-sm font-medium text-warm-700">{label}</span>
<span className="text-2xs text-warm-500">{sublabel}</span>
</Card>
);
}
const activityIconMap: Record<MockActivity['type'], { icon: LucideIcon; bg: string; color: string }> = {
'move': { icon: CheckCircle2, bg: 'bg-success-50', color: 'text-success-600' },
'consume': { icon: PackageMinus, bg: 'bg-warning-50', color: 'text-warning-600' },
'goods-receipt': { icon: ArrowRightLeft, bg: 'bg-info-50', color: 'text-info-600' },
'production-start': { icon: ArrowRightLeft, bg: 'bg-info-50', color: 'text-info-600' },
'production-complete': { icon: PackageCheck, bg: 'bg-success-50', color: 'text-success-600' },
};
function ActivityRow({ activity, onPress }: { activity: MockActivity; onPress: () => void }) {
const mapping = activityIconMap[activity.type];
const Icon = mapping.icon;
return (
<Card className="p-3 flex items-center gap-3 cursor-pointer" onClick={onPress}>
<div className={`w-9 h-9 rounded-lg ${mapping.bg} flex items-center justify-center shrink-0`}>
<Icon className={`w-4 h-4 ${mapping.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-warm-800 truncate">{activity.description}</div>
<div className="text-xs text-warm-500">{activity.detail}</div>
</div>
<ChevronRight className="w-4 h-4 text-warm-300 shrink-0" />
</Card>
);
}

View file

@ -0,0 +1,87 @@
import { useState } from 'react';
import { ScanLine, LogIn, Building2, AlertCircle } from 'lucide-react';
import { Button, Input } from '@effigenix/ui';
import { useNavigation } from '../navigation';
import { useApi } from '../api';
export function LoginPage() {
const { navigate } = useNavigation();
const { login } = useApi();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleLogin() {
setError(null);
setLoading(true);
try {
await login(username, password);
navigate('home');
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-6 bg-gradient-to-b from-warm-50 to-brand-100 animate-fade-in">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-4 shadow-lg bg-gradient-to-br from-brand-500 to-brand-700">
<ScanLine className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-warm-800 tracking-tight">Effigenix Mobile</h1>
<p className="text-warm-500 text-sm mt-1">Chargen &amp; Lager</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 mb-1">Betrieb</label>
<div className="flex items-center gap-2 w-full rounded-xl border border-warm-300 bg-white px-3 py-2 text-sm text-warm-700">
<Building2 className="w-4 h-4 text-warm-400 shrink-0" />
Metzgerei Huber Hauptbetrieb
</div>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 mb-1">Benutzer</label>
<Input
placeholder="Benutzername"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 mb-1">Passwort</label>
<Input
type="password"
placeholder="••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-danger-600 text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<Button size="lg" className="w-full mt-2" onClick={handleLogin} disabled={loading}>
<LogIn className="w-5 h-5" />
{loading ? 'Anmelden…' : 'Anmelden'}
</Button>
</div>
<p className="text-center text-warm-400 text-xs mt-8">
Verbunden mit Effigenix ERP · v2.0
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,54 @@
import { Search, Info } from 'lucide-react';
import { Button, Input, Card } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { PageHeader } from '../components/PageHeader';
import { useNavigation } from '../navigation';
import { useState } from 'react';
export function ManualEntryPage() {
const { navigate } = useNavigation();
const [codeType, setCodeType] = useState<'qr' | 'ean' | 'datamatrix'>('qr');
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<PageHeader title="Manuelle Eingabe" />
<div className="px-5 space-y-4 flex-1">
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Chargennummer</label>
<Input className="font-mono text-lg" placeholder="P-2026-03-19-003" defaultValue="P-2026-03-19-003" />
</div>
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">Code-Typ</label>
<div className="flex gap-2">
{(['qr', 'ean', 'datamatrix'] as const).map((type) => (
<Button
key={type}
variant={codeType === type ? 'primary' : 'secondary'}
className="flex-1"
onClick={() => setCodeType(type)}
>
{type === 'qr' ? 'QR-Code' : type === 'ean' ? 'EAN / GTIN' : 'DataMatrix'}
</Button>
))}
</div>
</div>
<Card className="p-3 bg-info-50 border-info-200 flex items-start gap-2.5">
<Info className="w-4 h-4 text-info-600 shrink-0 mt-0.5" />
<p className="text-xs text-info-700">
Sie können auch Lieferschein-Nummern, Artikel-Nummern oder interne Referenzen eingeben.
</p>
</Card>
</div>
<PageActions>
<Button size="lg" className="w-full" onClick={() => navigate('batch-detail')}>
<Search className="w-5 h-5" />
Charge suchen
</Button>
</PageActions>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { ScanLine, Home } from 'lucide-react';
import { Button } from '@effigenix/ui';
import { SuccessScreen } from '../components/SuccessScreen';
import { SummaryCard } from '../components/SummaryCard';
import { useNavigation } from '../navigation';
export function MoveConfirmPage() {
const { navigate, setFlowType } = useNavigation();
return (
<SuccessScreen
title="Umlagerung gebucht"
subtitle="P-2026-03-19-003 → Kühlhaus 2"
actions={
<>
<Button
className="w-full"
onClick={() => {
setFlowType('move');
navigate('scan');
}}
>
<ScanLine className="w-4 h-4" />
Nächste Charge scannen
</Button>
<Button variant="secondary" className="w-full" onClick={() => navigate('home')}>
<Home className="w-4 h-4" />
Zurück zum Start
</Button>
</>
}
>
<SummaryCard
rows={[
{ label: 'Charge', value: 'P-2026-03-19-003', mono: true },
{ label: 'Artikel', value: 'Wiener Würstchen' },
{ label: 'Menge', value: '118 kg', mono: true },
{ label: 'Von', value: 'Kühlhaus 1' },
{ label: 'Nach', value: 'Kühlhaus 2', highlight: 'success' },
{ label: 'Zeitpunkt', value: '19.03. 11:05' },
]}
/>
</SuccessScreen>
);
}

View file

@ -0,0 +1,107 @@
import { Check, ArrowDown, ThermometerSnowflake, Snowflake, ScanLine, ArrowRightLeft } from 'lucide-react';
import { Card, Button, Input, cn } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { PageHeader } from '../components/PageHeader';
import { StepIndicator } from '../components/StepIndicator';
import { useNavigation } from '../navigation';
import { BATCH, LOCATIONS } from '../mock-data';
import { useState } from 'react';
const locationIcons = {
cold: ThermometerSnowflake,
frozen: Snowflake,
scan: ScanLine,
};
export function MoveFlowPage() {
const { navigate } = useNavigation();
const [selected, setSelected] = useState(1);
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<PageHeader title="Umlagerung" />
<StepIndicator
steps={[
{ label: 'Charge', state: 'done' },
{ label: 'Ziel', state: 'active' },
{ label: 'Bestätigen', state: 'pending' },
]}
/>
<div className="px-5 space-y-4 flex-1">
{/* Scanned batch */}
<Card className="p-3 flex items-center gap-3 border-success-200 bg-success-50">
<div className="w-9 h-9 rounded-lg bg-success-100 flex items-center justify-center">
<Check className="w-4 h-4 text-success-600" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-warm-800">{BATCH.id}</div>
<div className="text-xs text-warm-500">
{BATCH.article} · {BATCH.quantity} {BATCH.unit}
</div>
</div>
<span className="text-xs text-warm-500">KH 1</span>
</Card>
{/* Divider */}
<div className="flex items-center gap-2 text-warm-400">
<div className="flex-1 h-px bg-warm-200" />
<ArrowDown className="w-4 h-4" />
<div className="flex-1 h-px bg-warm-200" />
</div>
{/* Target location */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-2">Ziel-Lagerort</label>
<div className="grid grid-cols-2 gap-2.5">
{LOCATIONS.map((loc, i) => {
const Icon = locationIcons[loc.icon];
return (
<Card
key={loc.name}
className={cn(
'p-3 text-center cursor-pointer border-2 transition',
selected === i ? 'border-brand-400 bg-brand-50' : 'border-transparent hover:border-brand-300'
)}
onClick={() => setSelected(i)}
>
<Icon className="w-5 h-5 text-info-500 mx-auto mb-1" />
<div className="text-sm font-semibold text-warm-800">{loc.name}</div>
<div className="text-2xs text-warm-500">{loc.temperature}</div>
</Card>
);
})}
<Card
className="p-3 text-center cursor-pointer border-2 border-transparent hover:border-brand-300 transition"
>
<ScanLine className="w-5 h-5 text-warm-400 mx-auto mb-1" />
<div className="text-sm font-semibold text-warm-800">Scannen</div>
<div className="text-2xs text-warm-500">Lagerort-QR</div>
</Card>
</div>
</div>
{/* Partial quantity */}
<div>
<label className="block text-sm font-medium text-warm-700 mb-1.5">
Teilmenge (optional)
</label>
<div className="flex items-center gap-3">
<Input className="font-mono text-center text-lg max-w-[120px]" placeholder="118" />
<span className="text-sm text-warm-500 font-medium">
von {BATCH.quantity} {BATCH.unit}
</span>
</div>
</div>
</div>
<PageActions>
<Button size="lg" className="w-full" onClick={() => navigate('move-confirm')}>
<ArrowRightLeft className="w-5 h-5" />
Umlagerung bestätigen
</Button>
</PageActions>
</div>
);
}

View file

@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { X, Flashlight, ScanLine, Keyboard } from 'lucide-react';
import { Button } from '@effigenix/ui';
import { PageActions } from '../components/PageActions';
import { useNavigation, type FlowType, type Page } from '../navigation';
import {
scan,
cancel,
Format,
checkPermissions,
requestPermissions,
} from '@tauri-apps/plugin-barcode-scanner';
const flowLabels: Record<FlowType, string> = {
move: 'Charge zum Umlagern scannen',
consume: 'Material scannen',
book: 'Produktionscharge scannen',
info: 'Charge scannen',
};
const flowTargets: Record<FlowType, Page> = {
move: 'batch-detail',
consume: 'consume-qty',
book: 'book-flow',
info: 'batch-detail',
};
export function ScannerPage() {
const { navigate, goBack, flowType } = useNavigation();
const [nativeAvailable, setNativeAvailable] = useState<boolean | null>(null);
function handleResult(content: string) {
navigate(flowTargets[flowType], { batchId: content });
}
useEffect(() => {
let cancelled = false;
let scanning = false;
(async () => {
// 1. Check if plugin is available (throws on desktop)
try {
let perm = await checkPermissions();
if (cancelled) return;
if (perm !== 'granted') perm = await requestPermissions();
if (cancelled) return;
if (perm !== 'granted') {
goBack();
return;
}
} catch {
if (!cancelled) setNativeAvailable(false);
return;
}
// 2. Start native scan — only if not cancelled by StrictMode cleanup
scanning = true;
try {
const result = await scan({
formats: [
Format.QRCode,
Format.DataMatrix,
Format.Code128,
Format.EAN13,
Format.EAN8,
],
});
scanning = false;
if (!cancelled) handleResult(result.content);
} catch {
scanning = false;
if (!cancelled) goBack();
}
})();
return () => {
cancelled = true;
if (scanning) cancel().catch(() => {});
};
}, []);
// Native scan in progress show minimal placeholder
if (nativeAvailable !== false) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-900">
<div className="text-center">
<ScanLine className="w-10 h-10 text-white/30 mx-auto mb-3 animate-pulse" />
<p className="text-white/50 text-sm">{flowLabels[flowType]}</p>
</div>
</div>
);
}
// Desktop fallback: simulated viewfinder
return (
<div className="flex-1 flex flex-col bg-warm-900 animate-fade-in">
<div className="flex-1 min-h-0 relative flex flex-col">
{/* Top bar */}
<div className="absolute top-0 inset-x-0 z-10 flex items-center justify-between px-5 pt-4">
<button
onClick={goBack}
className="w-10 h-10 rounded-full bg-black/30 backdrop-blur flex items-center justify-center"
>
<X className="w-5 h-5 text-white" />
</button>
<div className="px-3 py-1.5 rounded-full bg-black/30 backdrop-blur text-white text-xs font-semibold">
{flowLabels[flowType]}
</div>
<button className="w-10 h-10 rounded-full bg-black/30 backdrop-blur flex items-center justify-center">
<Flashlight className="w-5 h-5 text-white" />
</button>
</div>
{/* Camera simulation */}
<div
className="flex-1 flex items-center justify-center relative"
style={{
background: 'linear-gradient(180deg, #1a1611 0%, #2e2820 50%, #1a1611 100%)',
}}
>
<div className="w-64 h-64 relative">
<div className="absolute inset-0 border-2 border-white/20 rounded-2xl" />
<div className="absolute top-0 left-0 w-8 h-8 border-t-[3px] border-l-[3px] border-white rounded-tl-2xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-[3px] border-r-[3px] border-white rounded-tr-2xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-[3px] border-l-[3px] border-white rounded-bl-2xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-[3px] border-r-[3px] border-white rounded-br-2xl" />
<div className="absolute left-3 right-3 h-0.5 bg-brand-400 rounded-full animate-scan-line shadow-[0_0_12px_oklch(0.75_0.16_70/0.6)]" />
</div>
<p className="absolute bottom-8 text-white/50 text-sm font-medium text-center px-8">
QR-Code, DataMatrix oder Barcode in den Rahmen halten
</p>
</div>
<PageActions>
<Button size="lg" className="w-full" onClick={() => handleResult('P-2026-03-19-003')}>
<ScanLine className="w-5 h-5" />
Scan simulieren
</Button>
<Button
variant="secondary"
className="w-full bg-white/10 border-white/20 text-white hover:bg-white/20"
onClick={() => navigate('manual-entry')}
>
<Keyboard className="w-4 h-4" />
Manuell eingeben
</Button>
</PageActions>
</div>
</div>
);
}

View file

@ -0,0 +1,163 @@
import { Card, Badge } from '@effigenix/ui';
import { Loader2, AlertCircle, ClipboardList } from 'lucide-react';
import { BottomNav } from '../components/BottomNav';
import { useNavigation } from '../navigation';
import { useFlowState } from '../flow-state';
import { useApi } from '../api';
import { useState, useEffect } from 'react';
import type { ProductionOrderDTO } from '@effigenix/types';
const UOM_SHORT: Record<string, string> = {
KILOGRAM: 'kg',
GRAM: 'g',
LITER: 'L',
MILLILITER: 'mL',
PIECE: 'Stk',
METER: 'm',
};
function uomLabel(uom: string | undefined): string {
return (uom && UOM_SHORT[uom]) ?? uom ?? '';
}
const priorityConfig: Record<string, { variant: 'danger' | 'warning' | 'neutral'; label: string }> = {
URGENT: { variant: 'danger', label: 'Dringend' },
HIGH: { variant: 'warning', label: 'Hoch' },
NORMAL: { variant: 'neutral', label: 'Normal' },
LOW: { variant: 'neutral', label: 'Niedrig' },
};
const statusLabels: Record<string, string> = {
RELEASED: 'Freigegeben',
IN_PROGRESS: 'In Produktion',
};
interface EnrichedOrder {
order: ProductionOrderDTO;
articleName: string;
}
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ok'; data: EnrichedOrder[] };
export function TasksPage() {
const { navigate, setFlowType } = useNavigation();
const { setProductionOrder } = useFlowState();
const { client } = useApi();
const [state, setState] = useState<LoadState>({ status: 'loading' });
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [released, inProgress] = await Promise.all([
client.productionOrders.list({ status: 'RELEASED' }),
client.productionOrders.list({ status: 'IN_PROGRESS' }),
]);
if (cancelled) return;
const orders = [...inProgress, ...released];
// Enrich: recipe → article name
const enriched = await Promise.all(
orders.map(async (order): Promise<EnrichedOrder> => {
try {
if (!order.recipeId) return { order, articleName: '' };
const recipe = await client.recipes.getById(order.recipeId);
const article = await client.articles.getById(recipe.articleId);
return { order, articleName: article.name };
} catch {
return { order, articleName: '' };
}
}),
);
if (cancelled) return;
setState({ status: 'ok', data: enriched });
} catch {
if (!cancelled) setState({ status: 'error', message: 'Aufträge konnten nicht geladen werden' });
}
})();
return () => { cancelled = true; };
}, [client]);
function openOrder(e: EnrichedOrder) {
const { order } = e;
if (order.status !== 'IN_PROGRESS') return;
setProductionOrder({
orderId: order.id!,
recipeId: order.recipeId!,
batchNumber: order.batchNumber ?? null,
articleName: e.articleName,
plannedQuantity: parseFloat(order.plannedQuantity ?? '0'),
plannedQuantityUnit: order.plannedQuantityUnit ?? '',
});
setFlowType('consume');
navigate('consume-flow');
}
return (
<div className="min-h-screen flex flex-col animate-fade-in">
<div className="px-5 pt-6 pb-4">
<h1 className="text-lg font-bold text-warm-800">Aufgaben</h1>
<p className="text-xs text-warm-500">Offene Produktionsaufträge</p>
</div>
<div className="px-5 flex-1 space-y-2.5">
{state.status === 'loading' && (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-7 h-7 text-brand-500 animate-spin" />
</div>
)}
{state.status === 'error' && (
<Card className="p-5 text-center border-danger-200 bg-danger-50">
<AlertCircle className="w-7 h-7 text-danger-500 mx-auto mb-2" />
<p className="text-sm text-warm-700">{state.message}</p>
</Card>
)}
{state.status === 'ok' && state.data.length === 0 && (
<Card className="p-5 text-center">
<ClipboardList className="w-7 h-7 text-warm-400 mx-auto mb-2" />
<p className="text-sm text-warm-500">Keine offenen Aufträge</p>
</Card>
)}
{state.status === 'ok' && state.data.map((e) => {
const { order } = e;
const prio = priorityConfig[order.priority ?? 'NORMAL'] ?? priorityConfig.NORMAL;
const isClickable = order.status === 'IN_PROGRESS';
return (
<Card
key={order.id}
className={`p-4 ${isClickable ? 'cursor-pointer active:bg-warm-50' : 'opacity-75'}`}
onClick={() => isClickable && openOrder(e)}
>
<div className="flex items-center justify-between mb-2">
<Badge variant={prio.variant}>{prio.label}</Badge>
<span className="text-xs text-warm-500">
{statusLabels[order.status ?? ''] ?? order.status}
</span>
</div>
<div className="font-semibold text-warm-800 text-sm">
{e.articleName}
</div>
<div className="text-xs text-warm-500 mt-0.5">
{order.plannedQuantity} {uomLabel(order.plannedQuantityUnit)}
{order.plannedDate && ` · ${order.plannedDate}`}
{order.batchNumber && ` · ${order.batchNumber}`}
</div>
</Card>
);
})}
</div>
<BottomNav />
</div>
);
}

View file

@ -6,11 +6,14 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), react()], plugins: [tailwindcss(), react()],
resolve: {
conditions: ['source'],
},
clearScreen: false, clearScreen: false,
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host: host || '0.0.0.0',
hmr: host hmr: host
? { ? {
protocol: 'ws', protocol: 'ws',
@ -18,5 +21,11 @@ export default defineConfig({
port: 1421, port: 1421,
} }
: undefined, : undefined,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8080',
changeOrigin: true,
},
},
}, },
}); });

View file

@ -2,7 +2,7 @@
* Base axios client for the Effigenix API * Base axios client for the Effigenix API
*/ */
import axios, { type AxiosInstance } from 'axios'; import axios, { type AxiosAdapter, type AxiosInstance } from 'axios';
import { type ApiConfig, DEFAULT_API_CONFIG } from '@effigenix/config'; import { type ApiConfig, DEFAULT_API_CONFIG } from '@effigenix/config';
import { setupAuthInterceptor } from './interceptors/auth-interceptor.js'; import { setupAuthInterceptor } from './interceptors/auth-interceptor.js';
import { setupRefreshInterceptor } from './interceptors/refresh-interceptor.js'; import { setupRefreshInterceptor } from './interceptors/refresh-interceptor.js';
@ -30,6 +30,7 @@ export function createApiClient(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
}, },
...(resolvedConfig.adapter ? { adapter: resolvedConfig.adapter as AxiosAdapter } : {}),
}); });
setupAuthInterceptor(client, tokenProvider); setupAuthInterceptor(client, tokenProvider);

View file

@ -89,6 +89,11 @@ export function createStocksResource(client: AxiosInstance) {
return res.data; return res.data;
}, },
async findByBatchId(batchId: string): Promise<StockDTO[]> {
const res = await client.get<StockDTO[]>(`${BASE}/by-batch/${encodeURIComponent(batchId)}`);
return res.data;
},
async create(request: CreateStockRequest): Promise<CreateStockResponse> { async create(request: CreateStockRequest): Promise<CreateStockResponse> {
const res = await client.post<CreateStockResponse>(BASE, request); const res = await client.post<CreateStockResponse>(BASE, request);
return res.data; return res.data;

View file

@ -6,6 +6,8 @@ export interface ApiConfig {
baseUrl: string; baseUrl: string;
timeoutMs: number; timeoutMs: number;
retries: number; retries: number;
/** Custom axios adapter (e.g. Tauri HTTP plugin fetch). */
adapter?: unknown;
} }
export const DEFAULT_API_CONFIG: ApiConfig = { export const DEFAULT_API_CONFIG: ApiConfig = {

View file

@ -6,6 +6,7 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"source": "./src/index.ts",
"import": { "import": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"default": "./dist/index.js" "default": "./dist/index.js"

View file

@ -1,7 +1,7 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'; import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg'; type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@ -13,16 +13,18 @@ const variantStyles: Record<ButtonVariant, string> = {
primary: primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 focus-visible:ring-brand-500', 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 focus-visible:ring-brand-500',
secondary: secondary:
'bg-warm-100 text-warm-800 hover:bg-warm-200 active:bg-warm-300 focus-visible:ring-warm-400 border border-warm-300', 'bg-white text-warm-800 hover:bg-warm-50 active:bg-warm-100 focus-visible:ring-warm-400 border border-warm-300',
ghost: 'text-warm-700 hover:bg-warm-100 active:bg-warm-200 focus-visible:ring-warm-400', ghost: 'text-warm-700 hover:bg-warm-100 active:bg-warm-200 focus-visible:ring-warm-400',
danger: danger:
'bg-danger-600 text-white hover:bg-danger-700 active:bg-danger-800 focus-visible:ring-danger-500', 'bg-danger-600 text-white hover:bg-danger-700 active:bg-danger-800 focus-visible:ring-danger-500',
success:
'bg-success-600 text-white hover:bg-success-700 active:bg-success-800 focus-visible:ring-success-500',
}; };
const sizeStyles: Record<ButtonSize, string> = { const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm', sm: 'px-4 py-2 text-sm',
md: 'px-4 py-2 text-sm', md: 'px-6 py-3 text-sm',
lg: 'px-5 py-2.5 text-base', lg: 'px-8 py-4 text-base',
}; };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
@ -31,9 +33,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<button <button
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center font-medium rounded-md transition-colors', 'inline-flex items-center justify-center gap-2 font-semibold rounded-xl transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:pointer-events-none', 'disabled:opacity-50 disabled:pointer-events-none',
'active:scale-[0.97]',
variantStyles[variant], variantStyles[variant],
sizeStyles[size], sizeStyles[size],
className className

View file

@ -79,10 +79,16 @@
/* Font */ /* Font */
--font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
/* Radius */ /* Radius */
--radius-sm: 0.375rem; --radius-sm: 0.375rem;
--radius-md: 0.5rem; --radius-md: 0.5rem;
--radius-lg: 0.75rem; --radius-lg: 0.75rem;
--radius-xl: 1rem; --radius-xl: 1rem;
/* Shadows */
--shadow-xs: 0 1px 3px oklch(0 0 0 / 0.04);
--shadow-brand: 0 2px 8px oklch(0.65 0.19 55 / 0.3);
--shadow-brand-lg: 0 4px 16px oklch(0.65 0.19 55 / 0.4);
} }

View file

@ -108,6 +108,12 @@ importers:
'@tauri-apps/plugin-barcode-scanner': '@tauri-apps/plugin-barcode-scanner':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.4.4 version: 2.4.4
'@tauri-apps/plugin-http':
specifier: ^2.5.7
version: 2.5.7
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@18.3.1)
react: react:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1 version: 18.3.1
@ -1458,6 +1464,9 @@ packages:
'@tauri-apps/plugin-barcode-scanner@2.4.4': '@tauri-apps/plugin-barcode-scanner@2.4.4':
resolution: {integrity: sha512-uXvyMI8UgQjSrGxzTU5isNoQarMGRxFmTmb4TsgiWZHf/g7LsIyAQCwoFShjax0fXCK5mdVKDOvlkfOr21fo6g==} resolution: {integrity: sha512-uXvyMI8UgQjSrGxzTU5isNoQarMGRxFmTmb4TsgiWZHf/g7LsIyAQCwoFShjax0fXCK5mdVKDOvlkfOr21fo6g==}
'@tauri-apps/plugin-http@2.5.7':
resolution: {integrity: sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -2391,6 +2400,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.577.0:
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -4085,6 +4099,10 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-http@2.5.7':
dependencies:
'@tauri-apps/api': 2.10.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
@ -5087,6 +5105,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@0.577.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5

View file

@ -26,6 +26,16 @@ dev-scanner:
dev-ui: dev-ui:
cd frontend && pnpm run --filter @effigenix/ui dev cd frontend && pnpm run --filter @effigenix/ui dev
# ─── Android ─────────────────────────────────────────────
# Android-Emulator starten (Pixel 3 XL, API 34)
android-emulator:
emulator -avd effigenix -gpu swiftshader_indirect
# Scanner auf Android-Emulator starten (Emulator muss laufen)
dev-scanner-android:
cd frontend/apps/scanner && pnpm tauri android dev
# ─── Build ──────────────────────────────────────────────── # ─── Build ────────────────────────────────────────────────
# Frontend komplett bauen # Frontend komplett bauen