1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 06:39:34 +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.RecordStockMovement;
import de.effigenix.application.inventory.ActivateStorageLocation;
import de.effigenix.application.inventory.FindStockByBatchId;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.CheckStockExpiry;
@ -99,6 +100,11 @@ public class InventoryUseCaseConfiguration {
return new ListStocks(stockRepository);
}
@Bean
public FindStockByBatchId findStockByBatchId(StockRepository stockRepository) {
return new FindStockByBatchId(stockRepository);
}
@Bean
public AddStockBatch addStockBatch(StockRepository stockRepository, UnitOfWork 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
public Result<RepositoryError, Void> save(Stock stock) {
try {

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.FindStockByBatchId;
import de.effigenix.application.inventory.AddStockBatch;
import de.effigenix.application.inventory.BlockStockBatch;
import de.effigenix.application.inventory.ConfirmReservation;
@ -63,6 +64,7 @@ public class StockController {
private final GetStock getStock;
private final ListStocks listStocks;
private final ListStocksBelowMinimum listStocksBelowMinimum;
private final FindStockByBatchId findStockByBatchId;
private final AddStockBatch addStockBatch;
private final RemoveStockBatch removeStockBatch;
private final BlockStockBatch blockStockBatch;
@ -72,7 +74,7 @@ public class StockController {
private final ConfirmReservation confirmReservation;
public StockController(CreateStock createStock, UpdateStock updateStock, GetStock getStock, ListStocks listStocks,
ListStocksBelowMinimum listStocksBelowMinimum,
ListStocksBelowMinimum listStocksBelowMinimum, FindStockByBatchId findStockByBatchId,
AddStockBatch addStockBatch, RemoveStockBatch removeStockBatch,
BlockStockBatch blockStockBatch, UnblockStockBatch unblockStockBatch,
ReserveStock reserveStock, ReleaseReservation releaseReservation,
@ -82,6 +84,7 @@ public class StockController {
this.getStock = getStock;
this.listStocks = listStocks;
this.listStocksBelowMinimum = listStocksBelowMinimum;
this.findStockByBatchId = findStockByBatchId;
this.addStockBatch = addStockBatch;
this.removeStockBatch = removeStockBatch;
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
@GetMapping("/below-minimum")
@PreAuthorize("hasAuthority('STOCK_READ')")

View file

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

View file

@ -54,7 +54,7 @@ logging:
# CORS Configuration
effigenix:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:1420,tauri://localhost}
inventory:
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>> findAllByBatchId(String batchId) { return Result.success(List.of()); }
@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:
let
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 {
extensions = [ "rust-src" "rust-analyzer" ];
@ -25,6 +31,23 @@
"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
{
devShells.default = pkgs.mkShell {
@ -52,6 +75,13 @@
harfbuzz
atk
# ─── Backend ─────────────────────────
jdk21
# ─── Android ───────────────────────────
androidSdk
jdk17
# ─── Tools ─────────────────────────────
just
jq
@ -68,13 +98,36 @@
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 = ''
echo "Node $(node --version)"
echo "pnpm $(pnpm --version)"
echo "rustc $(rustc --version)"
echo "cargo $(cargo --version)"
echo "Java $(java --version 2>&1 | head -1)"
echo ""
echo "ANDROID_HOME=$ANDROID_HOME"
echo ""
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">
<head>
<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.gstatic.com" crossorigin />
<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"
/>
<title>Effigenix Scanner</title>
</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>
<script type="module" src="/src/main.tsx"></script>
</body>

View file

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

View file

@ -300,6 +300,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@ -334,10 +340,57 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"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]]
name = "core-foundation"
version = "0.10.1"
@ -361,7 +414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
@ -374,7 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@ -505,6 +558,12 @@ dependencies = [
"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]]
name = "deranged"
version = "0.5.8"
@ -626,6 +685,15 @@ dependencies = [
"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]]
name = "dom_query"
version = "0.27.0"
@ -686,6 +754,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-barcode-scanner",
"tauri-plugin-http",
]
[[package]]
@ -708,6 +777,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "equivalent"
version = "1.0.2"
@ -1037,8 +1115,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -1048,9 +1128,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1214,6 +1296,25 @@ dependencies = [
"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]]
name = "hashbrown"
version = "0.12.3"
@ -1324,6 +1425,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -1335,6 +1437,23 @@ dependencies = [
"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]]
name = "hyper-util"
version = "0.1.20"
@ -1353,9 +1472,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1721,6 +1842,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -1736,6 +1863,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@ -2454,6 +2587,22 @@ dependencies = [
"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]]
name = "quick-xml"
version = "0.38.4"
@ -2463,6 +2612,61 @@ dependencies = [
"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]]
name = "quote"
version = "1.0.45"
@ -2509,6 +2713,16 @@ dependencies = [
"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]]
name = "rand_chacha"
version = "0.2.2"
@ -2529,6 +2743,16 @@ dependencies = [
"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]]
name = "rand_core"
version = "0.5.1"
@ -2547,6 +2771,15 @@ dependencies = [
"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]]
name = "rand_hc"
version = "0.2.0"
@ -2640,6 +2873,49 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "reqwest"
version = "0.13.2"
@ -2674,6 +2950,20 @@ dependencies = [
"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]]
name = "rustc-hash"
version = "2.1.1"
@ -2689,12 +2979,53 @@ dependencies = [
"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]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -2903,6 +3234,18 @@ dependencies = [
"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]]
name = "serde_with"
version = "3.18.0"
@ -3141,6 +3484,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@ -3194,6 +3543,27 @@ dependencies = [
"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]]
name = "system-deps"
version = "6.2.2"
@ -3215,7 +3585,7 @@ checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch2",
@ -3292,7 +3662,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@ -3407,6 +3777,52 @@ dependencies = [
"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]]
name = "tauri-runtime"
version = "2.10.1"
@ -3609,6 +4025,21 @@ dependencies = [
"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]]
name = "tokio"
version = "1.50.0"
@ -3620,9 +4051,31 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"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]]
name = "tokio-util"
version = "0.7.18"
@ -3904,6 +4357,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -4150,6 +4609,16 @@ dependencies = [
"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]]
name = "web_atoms"
version = "0.2.3"
@ -4206,6 +4675,15 @@ dependencies = [
"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]]
name = "webview2-com"
version = "0.38.2"
@ -4391,6 +4869,17 @@ dependencies = [
"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]]
name = "windows-result"
version = "0.3.4"
@ -4436,6 +4925,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.59.0"
@ -4924,6 +5422,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View file

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

View file

@ -3,6 +3,19 @@
"description": "Default capabilities for the scanner app",
"windows": ["main"],
"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)]
pub fn run() {
let builder = tauri::Builder::default();
let builder = tauri::Builder::default()
.plugin(tauri_plugin_http::init());
#[cfg(mobile)]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
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!())
.expect("error while running tauri application");
}

View file

@ -14,9 +14,10 @@
"windows": [
{
"title": "Effigenix Scanner",
"width": 400,
"height": 700,
"resizable": true
"width": 645,
"height": 1305,
"resizable": true,
"zoomHotkeysEnabled": true
}
],
"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() {
return (
<div className="min-h-screen p-6">
<div className="mx-auto max-w-md space-y-6">
<h1 className="text-2xl font-bold text-brand-700">Effigenix Scanner</h1>
<Card>
<h2 className="mb-4 text-lg font-semibold">Chargen-Scanner</h2>
<p className="mb-4 text-sm text-warm-600">
QR-Code oder Barcode scannen, um Chargen-Details anzuzeigen.
</p>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={() => {
// TODO: Barcode-Scanner Integration
console.log('Scan gestartet');
}}
>
Scan starten
</Button>
</Card>
</div>
</div>
<ApiProvider>
<NavigationProvider>
<FlowStateProvider>
<div className="h-dvh flex flex-col bg-warm-50">
<StatusBar />
<div className="flex-1 min-h-0 flex flex-col">
<Router />
</div>
</div>
</FlowStateProvider>
</NavigationProvider>
</ApiProvider>
);
}

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 '@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({
plugins: [tailwindcss(), react()],
resolve: {
conditions: ['source'],
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
host: host || '0.0.0.0',
hmr: host
? {
protocol: 'ws',
@ -18,5 +21,11 @@ export default defineConfig({
port: 1421,
}
: 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
*/
import axios, { type AxiosInstance } from 'axios';
import axios, { type AxiosAdapter, type AxiosInstance } from 'axios';
import { type ApiConfig, DEFAULT_API_CONFIG } from '@effigenix/config';
import { setupAuthInterceptor } from './interceptors/auth-interceptor.js';
import { setupRefreshInterceptor } from './interceptors/refresh-interceptor.js';
@ -30,6 +30,7 @@ export function createApiClient(
'Content-Type': 'application/json',
Accept: 'application/json',
},
...(resolvedConfig.adapter ? { adapter: resolvedConfig.adapter as AxiosAdapter } : {}),
});
setupAuthInterceptor(client, tokenProvider);

View file

@ -89,6 +89,11 @@ export function createStocksResource(client: AxiosInstance) {
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> {
const res = await client.post<CreateStockResponse>(BASE, request);
return res.data;

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '../lib/cn';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@ -13,16 +13,18 @@ const variantStyles: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 focus-visible:ring-brand-500',
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',
danger:
'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> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-sm',
lg: 'px-8 py-4 text-base',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
@ -31,9 +33,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<button
ref={ref}
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',
'disabled:opacity-50 disabled:pointer-events-none',
'active:scale-[0.97]',
variantStyles[variant],
sizeStyles[size],
className

View file

@ -79,10 +79,16 @@
/* Font */
--font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
/* Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--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':
specifier: ^2.0.0
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:
specifier: ^18.2.0
version: 18.3.1
@ -1458,6 +1464,9 @@ packages:
'@tauri-apps/plugin-barcode-scanner@2.4.4':
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':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -2391,6 +2400,11 @@ packages:
lru-cache@5.1.1:
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:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -4085,6 +4099,10 @@ snapshots:
dependencies:
'@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':
dependencies:
'@babel/parser': 7.29.2
@ -5087,6 +5105,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.577.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

View file

@ -26,6 +26,16 @@ dev-scanner:
dev-ui:
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 ────────────────────────────────────────────────
# Frontend komplett bauen