@@ -399,6 +429,22 @@ $pagination_entity_name = "Rechnungen";
todayBtn: 'linked',
autoclose: true
});
+ $('#manual_invoice_date_from').datepicker({
+ orientation: "bottom",
+ language: 'de',
+ format: "dd.mm.yyyy",
+ showWeekDays: true,
+ todayBtn: 'linked',
+ autoclose: true
+ });
+ $('#manual_invoice_date_to').datepicker({
+ orientation: "bottom",
+ language: 'de',
+ format: "dd.mm.yyyy",
+ showWeekDays: true,
+ todayBtn: 'linked',
+ autoclose: true
+ });
$('.datepicker').datepicker({
orientation: "bottom",
language: 'de',
diff --git a/Layout/default/ManualInvoice/PDF_HEADER.html b/Layout/default/ManualInvoice/PDF_HEADER.html
index 075f2c9bc..49e6196c0 100644
--- a/Layout/default/ManualInvoice/PDF_HEADER.html
+++ b/Layout/default/ManualInvoice/PDF_HEADER.html
@@ -68,9 +68,7 @@
-
-
- |
+ {{ qrCodeHtml }}
diff --git a/Layout/default/ManualInvoice/PDF_MAIN.php b/Layout/default/ManualInvoice/PDF_MAIN.php
index 49ef1d468..3e6523035 100644
--- a/Layout/default/ManualInvoice/PDF_MAIN.php
+++ b/Layout/default/ManualInvoice/PDF_MAIN.php
@@ -17,7 +17,7 @@ foreach($invoice->positions as $p) {
}
}
-$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
+$total_discount = $invoice->total_discount ?? 0;
$subtotal = 0;
foreach($invoice->positions as $p) {
$subtotal += $p->price_total ?? 0;
@@ -127,8 +127,8 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
Ihre Xinon =($is_credit) ? "Gutschrift" : "Rechnung"?> vom =date("d.m.Y",$invoice->invoice_date)?>
- einleitender_text ?? ''): ?>
- =nl2br(htmlspecialchars($invoice->einleitender_text))?>
+ introductory_text ?? ''): ?>
+ =nl2br(htmlspecialchars($invoice->introductory_text))?>
@@ -166,7 +166,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
">
|
- =htmlspecialchars($p->product_name ?? '')?>
+ =htmlspecialchars($p->warehousearticle_name ?? '')?>
product_info) && $p->product_info): ?>
=htmlspecialchars($p->product_info)?>
@@ -186,17 +186,17 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
endforeach;
endforeach;
?>
- 0): ?>
+ 0): ?>
|
| Zwischensumme: |
=number_format($subtotal, 2, ",","."). " €"?> |
- | Gesamtrabatt =number_format($gesamtrabatt, 2, ",", ".")?>%: |
- -=number_format($subtotal * ($gesamtrabatt / 100), 2, ",","."). " €"?> |
+ Gesamtrabatt =number_format($total_discount, 2, ",", ".")?>%: |
+ -=number_format($subtotal * ($total_discount / 100), 2, ",","."). " €"?> |
-
+
| Gesamt Netto: |
=number_format($net_total, 2, ",","."). " €"?> |
diff --git a/Layout/default/MobileApp/App.php b/Layout/default/MobileApp/App.php
new file mode 100644
index 000000000..a7f33fb95
--- /dev/null
+++ b/Layout/default/MobileApp/App.php
@@ -0,0 +1,9 @@
+ 'Xinon Mobile',
+ 'appName' => 'Xinon',
+ 'manifestPath' => '/mobile/manifest.json',
+ 'appJsPath' => '/mobile/app.js',
+ 'swPath' => '/mobile/sw.js',
+];
+require __DIR__ . '/Base.php';
diff --git a/Layout/default/MobileApp/Base.php b/Layout/default/MobileApp/Base.php
new file mode 100644
index 000000000..63f7f6500
--- /dev/null
+++ b/Layout/default/MobileApp/Base.php
@@ -0,0 +1,71 @@
+ 'Xinon Mobile',
+ 'appName' => 'Xinon',
+ 'manifestPath' => '/mobile/manifest.json',
+ 'appJsPath' => '/mobile/app.js',
+ 'swPath' => '/mobile/sw.js',
+ 'additionalStylesheets' => [],
+], $appConfig ?? []);
+?>
+
+
+
+
+
+ = htmlspecialchars($config['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 
+ 
+ Lädt...
+
+
+
+
+
+
+
diff --git a/Layout/default/MobileApp/WarehouseStocktake.php b/Layout/default/MobileApp/WarehouseStocktake.php
new file mode 100644
index 000000000..738bab8ed
--- /dev/null
+++ b/Layout/default/MobileApp/WarehouseStocktake.php
@@ -0,0 +1,10 @@
+ 'Lager Inventur',
+ 'appName' => 'Inventur',
+ 'manifestPath' => '/mobile/warehouse-stocktake/manifest.json',
+ 'appJsPath' => '/mobile/warehouse-stocktake/app.js',
+ 'swPath' => '/mobile/warehouse-stocktake/sw.js',
+ 'additionalStylesheets' => ['/mobile/warehouse-stocktake/app.css'],
+];
+require __DIR__ . '/Base.php';
diff --git a/Layout/default/Order/Form.php b/Layout/default/Order/Form.php
index a513c694e..5ff2e19c1 100644
--- a/Layout/default/Order/Form.php
+++ b/Layout/default/Order/Form.php
@@ -437,7 +437,7 @@
@@ -553,7 +553,6 @@
-
Produkte
@@ -585,6 +584,9 @@
+ preorder_id): ?>
+
+
-
-
-
+ preorder_id): ?>
+
+
@@ -641,7 +645,17 @@
-
+
+ product->getAttributeValue("oaid_enabled")): ?>
+
+
+
product->attributes) && count($product->product->attributes))
&& (array_key_exists(TT_ATTRIB_TERMINATION_REQUIRED_NAME, $product->product->attributes)
@@ -828,7 +842,7 @@
@@ -869,7 +883,14 @@
-
+
+
+
@@ -1383,8 +1404,15 @@
});
} else {
$('#termination_id-' + id + '-line').hide();
- //$('#termination_id-' + id + '-line').hide();
}
+
+ if(typeof p.attributes === 'object' && "oaid_enabled" in p.attributes && p.attributes.oaid_enabled == 1) {
+ console.log("oaid_enabled");
+ $('#oaid-' + id + '-line').show();
+ console.log($('#oaid-' + id).val());
+ } else {
+ $('#oaid-' + id + '-line').hide();
+ }
if(typeof p.attributes === 'object' && "needs_number" in p.attributes && p.attributes.needs_number == 1) {
console.log("needs_number");
@@ -1893,7 +1921,7 @@
} else {
$('#products-' + id + '-delete').val(0);
//$('#position-' + id + ' .delete-button-container i').removeClass("fa-trash-can-slash").addClass("fa-trash-can");
- $('#position-' + id + ' .delete-button-container').html(' ');
+ $('#position-' + id + ' .delete-button-container').html(' ');
//$('#position-' + id + ' .delete-button-container i').removeClass("btn-outline-white fa-trash-can-slash").addClass("text-danger fa-trash-can");
$('#position-' + id).removeClass('text-white deleted');
@@ -1932,7 +1960,7 @@
\
\
\
@@ -1973,7 +2001,14 @@
\
\
\
- \
+\
+ \
+\
\
\
\
diff --git a/Layout/default/Order/Index.php b/Layout/default/Order/Index.php
index 35974b831..8e44ad0e2 100644
--- a/Layout/default/Order/Index.php
+++ b/Layout/default/Order/Index.php
@@ -299,6 +299,10 @@
$order->id])?>" onclick="if(!confirm('Soll der Service-PIN an den Vertragsinhaber gesendet werden?')) return false;">
$order->id, "filter" => $filter, "noTermProducts" => 1])?>">
$order->id])?>" onclick="if(!confirm('Bestellung wirklich löschen?')) return false;" class="text-danger" title="Löschen">
+
+ getSnoppProduct() && ($order->getPreorderProduct() || $order->getOaidProduct())): ?>
+ $order->id, "filter" => $filter, "noTermProducts" => 1])?>" class="ml-2">?>img/snop-logo.png)
+
" id="order-dates-=$order->id?>">
@@ -543,7 +547,10 @@
| =$product->pos?> |
=$product->formatAmount()?> |
- =$product->product->name?>
+ snopp_order_id): ?>
+
+
+ =$product->product->name?> =$product->oaid ? "{$product->oaid}" : ""?>
product->attributes) && count($product->product->attributes))
diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php
index 6a1a44cbb..2f8219386 100644
--- a/Layout/default/Preorder/Index.php
+++ b/Layout/default/Preorder/Index.php
@@ -888,9 +888,10 @@ $pagination_entity_name = "Vorbestellungen";
Filter-Vorlagen
|
+
+
+
@@ -697,6 +711,7 @@
ctag |
Typ |
External ID |
+ External Name |
External State |
ctags) && count($preorder->ctags)): ?>
@@ -706,6 +721,7 @@
=$ctag->ctag?> |
=$ctag->service_type?> |
=htmlentities($ctag->ext_id)?> |
+ =htmlentities($ctag->ext_name)?> |
=htmlentities($ctag->ext_status)?> |
diff --git a/Layout/default/Voicenumber/Form.php b/Layout/default/Voicenumber/Form.php
index 1d2e94ac7..ed90b52e4 100644
--- a/Layout/default/Voicenumber/Form.php
+++ b/Layout/default/Voicenumber/Form.php
@@ -117,6 +117,7 @@
+
diff --git a/Layout/default/Voicenumberblock/include/block-detail.php b/Layout/default/Voicenumberblock/include/block-detail.php
index 918bd4ebb..d9fa9ff16 100644
--- a/Layout/default/Voicenumberblock/include/block-detail.php
+++ b/Layout/default/Voicenumberblock/include/block-detail.php
@@ -17,14 +17,16 @@
id, $num_from) ? $num_from[$block->id] : $block->first), $block->last) as $number): ?>
$block->id, 'number' => $number]) ?>
-
+ ">
| =$number?> |
active): ?>
-
- (seit =($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)
+
+ (seit =($num->id) ? date("d.m.Y H:i:s", $num->activated_date) : ""?>)
+ disabled): ?>
+
-
+
|
$num->contract_id])?>">=$num->contract_id?> |
@@ -40,7 +42,7 @@
Lokal
- =$num->disabled_reason?> |
+ =$num->disabled_reason?>=($num->disabled > 1) ? " (".date("d.m.Y H:i", $num->disabled).")" : ""?> |
=($num->id && $num->enable_on_date) ? date("d.m.Y", $num->enable_on_date) : ""?> |
$block->id, "number" => $number])?>">
diff --git a/Layout/default/VueViews/WarehouseStocktakePWA.php b/Layout/default/VueViews/WarehouseStocktakePWA.php
index 9c65502bb..8da06d02c 100644
--- a/Layout/default/VueViews/WarehouseStocktakePWA.php
+++ b/Layout/default/VueViews/WarehouseStocktakePWA.php
@@ -1,4 +1,12 @@
id) {
+ $openreplayUserId = (string) $user->id;
+ }
+}
?>
@@ -32,6 +40,34 @@
}
+
+
+
-
+
diff --git a/Layout/default/WarehouseArticle/LABEL_BULK.php b/Layout/default/WarehouseArticle/LABEL_BULK.php
new file mode 100644
index 000000000..b6fa823c7
--- /dev/null
+++ b/Layout/default/WarehouseArticle/LABEL_BULK.php
@@ -0,0 +1,54 @@
+ QRCode::OUTPUT_IMAGE_PNG,
+ 'scale' => 10,
+ 'quietzoneSize' => 1,
+]);
+$qrcode = new QRCode($options);
+?>
+
+
+
+
+
+
+id . ":" . $article->articleNumber;
+ $qrCodeBase64 = $qrcode->render($qrData);
+?>
+
+
+
+
+
+ |
+
+
+ articleNumber); ?>
+ title); ?>
+ |
+
+
+
+
+
+
diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php
index 88888077d..37bde4d2f 100644
--- a/Layout/default/WarehouseOffer/PDF_MAIN.php
+++ b/Layout/default/WarehouseOffer/PDF_MAIN.php
@@ -61,8 +61,10 @@ if ($includeTax) {
}
$formattedOfferDate = date("d.m.Y", $offerDate);
-$validityDays = isset($validity) ? (int)$validity : 14;
-$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate));
+$validityDays = isset($validity) ? (int)$validity : 31;
+// Use versionDate (when this version was created) for validity calculation, fallback to offerDate
+$validityBaseDate = isset($versionDate) ? $versionDate : $offerDate;
+$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $validityBaseDate));
?>
diff --git a/Layout/default/WarehouseShippingNote/PDF_HEADER.html b/Layout/default/WarehouseShippingNote/PDF_HEADER.html
index 6c6bd5781..40c2a07ff 100644
--- a/Layout/default/WarehouseShippingNote/PDF_HEADER.html
+++ b/Layout/default/WarehouseShippingNote/PDF_HEADER.html
@@ -30,6 +30,7 @@
.invoice-details td {
text-align: left;
+ white-space: nowrap;
}
.invoice-details td:first-child {
diff --git a/Layout/default/header.php b/Layout/default/header.php
index 96ffbbad7..e27ba457b 100644
--- a/Layout/default/header.php
+++ b/Layout/default/header.php
@@ -99,6 +99,7 @@
+
diff --git a/Layout/default/includes/openreplay.php b/Layout/default/includes/openreplay.php
new file mode 100644
index 000000000..b82a4148f
--- /dev/null
+++ b/Layout/default/includes/openreplay.php
@@ -0,0 +1,79 @@
+
+ *
+ * Variables that can be set before including:
+ * - $openreplayUserType: 'internal' (default) or 'external'
+ * - $openreplayDisabled: set to true to disable tracking
+ */
+
+if (!empty($openreplayDisabled)) return;
+
+$openreplayUserId = '';
+$openreplayUserName = '';
+$openreplayUserType = $openreplayUserType ?? 'internal';
+$openreplayMetadata = [];
+
+// Get user info for internal users
+if (class_exists('mfUser') && class_exists('mfLoginController') && mfLoginController::isLoggedIn()) {
+ $user = mfUser::singleton();
+ if ($user && $user->id) {
+ $openreplayUserId = (string) $user->id;
+ $openreplayUserName = $user->username ?? '';
+ $openreplayMetadata['userType'] = $openreplayUserType;
+ $openreplayMetadata['username'] = $openreplayUserName;
+ }
+}
+
+// Allow override from JSGlobals (for PWA contexts)
+if (isset($JSGlobals['OPENREPLAY_USER_ID'])) {
+ $openreplayUserId = (string) $JSGlobals['OPENREPLAY_USER_ID'];
+}
+if (isset($JSGlobals['OPENREPLAY_USER_TYPE'])) {
+ $openreplayUserType = $JSGlobals['OPENREPLAY_USER_TYPE'];
+ $openreplayMetadata['userType'] = $openreplayUserType;
+}
+if (isset($JSGlobals['OPENREPLAY_COMPANY_ID'])) {
+ $openreplayMetadata['companyId'] = $JSGlobals['OPENREPLAY_COMPANY_ID'];
+}
+
+// Disable on dev environment if needed
+$openreplayEnabled = true;
+if (defined('MFAPPNAME') && MFAPPNAME === 'devthetool') {
+ // Optionally disable on dev - comment out to enable on dev too
+ // $openreplayEnabled = false;
+}
+
+if ($openreplayEnabled):
+?>
+
+
diff --git a/Layout/default/menu.php b/Layout/default/menu.php
index baf61ddc7..7061bab1a 100644
--- a/Layout/default/menu.php
+++ b/Layout/default/menu.php
@@ -141,9 +141,9 @@
is(["Admin","netowner","pipeplanner","pipeplanner"]) && $me->is("employee")): ?>"> Verteiler und Schächte
is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>"> Rohrverzeichnis
is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?>"> Kabelverzeichnis
-
can("RMLCompany")): ?>"> Arbeitsaufträge
can("RMLAdmin")): ?>"> Arbeitsaufträge-Management
+ can("RMLAdmin")): ?>"> Arbeitsaufträge-Dashboard
can("WorkorderMph")): ?>"> MPH Arbeitsaufträge
can("WorkorderMphAdmin")): ?>"> MPH Arbeitsaufträge Verwaltung
@@ -185,6 +185,7 @@
can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>"> Lieferscheine
can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>"> Projekte
can("WarehouseAdmin")): ?>"> Inventur
+ can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>"> Lagerbewegung
can("WarehouseAdmin")): ?>"> Administration
diff --git a/Layout/default/vueHeader3.php b/Layout/default/vueHeader3.php
index f4a126f50..fe4ede8ca 100644
--- a/Layout/default/vueHeader3.php
+++ b/Layout/default/vueHeader3.php
@@ -69,6 +69,8 @@
+
+
diff --git a/application/ADBHausnummer/ADBHausnummerModel.php b/application/ADBHausnummer/ADBHausnummerModel.php
index d589558f7..8703b8cd8 100644
--- a/application/ADBHausnummer/ADBHausnummerModel.php
+++ b/application/ADBHausnummer/ADBHausnummerModel.php
@@ -147,7 +147,7 @@ class ADBHausnummerModel {
$sql .= " WHERE $where";
- if (!empty($filter['home_oaid_rimo_id'])) {
+ if (!empty($filter['home_oaid_rimo_id']) && !$join_tables) {
$sql .= " GROUP BY Hausnummer.id";
}
diff --git a/application/ADBNetzgebiet/ADBNetzgebiet.php b/application/ADBNetzgebiet/ADBNetzgebiet.php
index 54d60385e..b560bddd6 100644
--- a/application/ADBNetzgebiet/ADBNetzgebiet.php
+++ b/application/ADBNetzgebiet/ADBNetzgebiet.php
@@ -41,6 +41,10 @@ class ADBNetzgebiet extends mfBaseModelV2 {
public ?string $source_id = null;
public ?string $borderpoly = null;
public ?string $freigabe = '["interest", "provision", "order", "reorder"]';
+ public int $unit_count = 0;
+ public int $unit_count_sd = 0;
+ public int $unit_count_md = 0;
+ public int $unit_create_oaid = 0;
public ?string $options = '{"create_address_parts": 0, "update_freigabe": 1, "update_address": 1, "hausnummer_dont_overwrite_netzgebiet": 0, "create_preorder": 0, "preorder_only_oaid": 0, "wo_ignore_status": 0, "delete_units": 0, "mph_min_homes_tool_automatic_count": 3, "unit_create_oaid": 0}';
public int $create;
public int $edit;
diff --git a/application/ADBNetzgebiet/ADBNetzgebietController.php b/application/ADBNetzgebiet/ADBNetzgebietController.php
index c4304dcd3..414546f4a 100644
--- a/application/ADBNetzgebiet/ADBNetzgebietController.php
+++ b/application/ADBNetzgebiet/ADBNetzgebietController.php
@@ -24,6 +24,9 @@ class ADBNetzgebietController extends mfBaseController {
"GET_URL" => $this::getUrl("ADBNetzgebiet/getNetzgebiete"),
"SAVE_URL" => $this::getUrl("ADBNetzgebiet/save"),
"HISTORY_URL" => $this::getUrl("ADBNetzgebiet/getHistory"),
+ "START_RIMO_IMPORT_URL" => $this::getUrl("ADBNetzgebiet/startRimoImport"),
+ "GET_RIMO_IMPORT_STATUS_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportStatus"),
+ "GET_RIMO_IMPORT_LOG_URL" => $this::getUrl("ADBNetzgebiet/getRimoImportLog"),
"NETWORK_URL" => $this::getUrl("Network/Index"),
"NETWORK_CREATE_URL" => $this::getUrl("Network/add"),
"CAMPAIGN_URL" => $this::getUrl("Preordercampaign/edit"),
@@ -130,6 +133,193 @@ class ADBNetzgebietController extends mfBaseController {
self::returnJson(['success' => true, 'data' => $history]);
}
+ protected function startRimoImportAction(): void {
+ $id = $_GET['id'] ?? null;
+ if (empty($id)) {
+ self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
+ return;
+ }
+
+ $netzgebiet = ADBNetzgebiet::get($id);
+ if (!$netzgebiet || !$netzgebiet->id) {
+ self::returnJson(['success' => false, 'message' => "Netzgebiet not found."]);
+ return;
+ }
+
+ if (strpos($netzgebiet->source, 'rimo-') !== 0) {
+ self::returnJson(['success' => false, 'message' => "This action is only for RIMO-source Netzgebiete."]);
+ return;
+ }
+
+ if (empty($netzgebiet->source_id)) {
+ self::returnJson(['success' => false, 'message' => "Netzgebiet has no Source ID."]);
+ return;
+ }
+
+ $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
+ $importTempDir = TEMP_DIR . "/ADBNetzgebietRimoImport/";
+ $logDir = $importTempDir . $safeSourceId;
+ $logFile = $logDir . "/import.log";
+ $lockFile = $logDir . "/import.lock";
+
+ if (is_dir($importTempDir)) {
+ foreach (glob($importTempDir . "*") as $dir) {
+ if (is_dir($dir) && (time() - filemtime($dir)) > 86400) {
+ // simple cleanup
+ if (file_exists($dir . "/import.log")) @unlink($dir . "/import.log");
+ if (file_exists($dir . "/import.lock")) @unlink($dir . "/import.lock");
+ @rmdir($dir);
+ }
+ }
+ }
+
+ if (!is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ if (file_exists($lockFile)) {
+ if ((time() - filemtime($lockFile)) > 3600) { // stale lock for 1h
+ @unlink($lockFile);
+ } else {
+ self::returnJson(['success' => false, 'message' => "Import is already running.", 'status' => 'running']);
+ return;
+ }
+ }
+
+ if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
+ $remaining = 900 - (time() - filemtime($logFile));
+ self::returnJson(['success' => false, 'message' => "Please wait before starting another import.", 'status' => 'cooldown', 'remaining' => $remaining]);
+ return;
+ }
+
+ touch($lockFile);
+
+ $projectRoot = dirname(dirname(__DIR__));
+ $scriptRelativePath = 'scripts/adb-rimo-import/rimo-import.php';
+ $scriptFullPath = $projectRoot . '/' . $scriptRelativePath;
+
+ if (!file_exists($scriptFullPath)) {
+ self::returnJson(['success' => false, 'message' => "Import script not found."]);
+ return;
+ }
+
+ $php_executable = "php";
+ $command = "$php_executable $scriptRelativePath " . escapeshellarg($netzgebiet->source_id);
+
+ $bgCommand = 'cd ' . escapeshellarg($projectRoot) . ' && ' . $command . ' > ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
+ $pid = shell_exec($bgCommand);
+
+ if(empty($pid) || !is_numeric(trim($pid))) {
+ self::returnJson(['success' => false, 'message' => "Failed to start background process."]);
+ return;
+ }
+
+ file_put_contents($lockFile, trim($pid));
+
+ self::returnJson(['success' => true, 'message' => 'RIMO import started.']);
+ }
+
+ protected function getRimoImportStatusAction(): void {
+ $ids = $this->postData['ids'] ?? [];
+ if (empty($ids)) {
+ self::returnJson(['success' => true, 'data' => []]);
+ return;
+ }
+
+ $statuses = [];
+ foreach ($ids as $id) {
+ $netzgebiet = ADBNetzgebiet::get($id);
+ if (!$netzgebiet || !$netzgebiet->id || strpos($netzgebiet->source, 'rimo-') !== 0 || empty($netzgebiet->source_id)) {
+ $statuses[$id] = ['status' => 'not_applicable'];
+ continue;
+ }
+
+ $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
+ $logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
+ $logFile = $logDir . "/import.log";
+ $lockFile = $logDir . "/import.lock";
+
+ if (file_exists($lockFile)) {
+ $pid = trim(file_get_contents($lockFile));
+ // Check if process is still running. posix_getpgid returns false if process does not exist.
+ if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
+ $statuses[$id] = ['status' => 'running'];
+ } else {
+ // Stale lock file, process is gone.
+ @unlink($lockFile);
+ // Check for cooldown based on log file from the finished process
+ if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
+ $statuses[$id] = [
+ 'status' => 'cooldown',
+ 'remaining' => 900 - (time() - filemtime($logFile))
+ ];
+ } else {
+ $statuses[$id] = ['status' => 'idle'];
+ }
+ }
+ } elseif (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
+ $statuses[$id] = [
+ 'status' => 'cooldown',
+ 'remaining' => 900 - (time() - filemtime($logFile))
+ ];
+ } else {
+ $statuses[$id] = ['status' => 'idle'];
+ }
+ }
+ self::returnJson(['success' => true, 'data' => $statuses]);
+ }
+
+ protected function getRimoImportLogAction(): void {
+ $id = $_GET['id'] ?? null;
+ if (empty($id)) {
+ self::returnJson(['success' => false, 'message' => "Netzgebiet ID required."]);
+ return;
+ }
+
+ $netzgebiet = ADBNetzgebiet::get($id);
+ if (!$netzgebiet || !$netzgebiet->id || empty($netzgebiet->source_id)) {
+ self::returnJson(['success' => false, 'message' => "Netzgebiet not found or not applicable."]);
+ return;
+ }
+
+ $safeSourceId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $netzgebiet->source_id);
+ $logDir = TEMP_DIR . "/ADBNetzgebietRimoImport/" . $safeSourceId;
+ $logFile = $logDir . "/import.log";
+ $lockFile = $logDir . "/import.lock";
+
+ $logContent = "";
+ if (file_exists($logFile)) {
+ $logContent = file_get_contents($logFile);
+ }
+
+ $status = 'idle';
+ if (file_exists($lockFile)) {
+ $pid = trim(file_get_contents($lockFile));
+ if (is_numeric($pid) && posix_getpgid((int)$pid) !== false) {
+ $status = 'running';
+ } else {
+ @unlink($lockFile); // Stale lock, process is gone
+ }
+ }
+
+ if ($status !== 'running') {
+ if (file_exists($logFile) && (time() - filemtime($logFile)) < 900) {
+ $status = 'cooldown';
+ } else {
+ $status = file_exists($logFile) ? 'finished' : 'idle';
+ }
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'data' => [
+ 'log' => $logContent,
+ 'status' => $status,
+ 'timestamp' => file_exists($logFile) ? filemtime($logFile) : null
+ ]
+ ]);
+ }
+
// TODO: Implement RIMO API check
protected function checkRimoSourceIdAction(): void {
self::returnJson(['success' => false, 'message' => "RIMO API check not available."]);
diff --git a/application/Address/AddressController.php b/application/Address/AddressController.php
index d514a785d..129f420a5 100644
--- a/application/Address/AddressController.php
+++ b/application/Address/AddressController.php
@@ -333,6 +333,8 @@ class AddressController extends mfBaseController {
$data['fibu_supplier_due'] = ($r->fibu_supplier_due) ? trim($r->fibu_supplier_due) : null;
$data['fibu_supplier_skonto'] = ($r->fibu_supplier_skonto) ? trim($r->fibu_supplier_skonto) : null;
$data['fibu_supplier_skonto_rate'] = ($r->fibu_supplier_skonto_rate) ? trim($r->fibu_supplier_skonto_rate) : null;
+
+ $data["manual_invoice_sepa_limit"] = ($r->manual_invoice_sepa_limit) ? str_replace(",", ".", trim($r->manual_invoice_sepa_limit)) : null;
}
diff --git a/application/Api/v1/AddressdbApicontroller.php b/application/Api/v1/AddressdbApicontroller.php
index 06d579716..ad59a805e 100644
--- a/application/Api/v1/AddressdbApicontroller.php
+++ b/application/Api/v1/AddressdbApicontroller.php
@@ -131,7 +131,7 @@ class AddressdbApicontroller extends mfBaseApicontroller {
protected function getClusters() {
$cluster_search = [];
if(count($this->filter_salescluster_ids)) {
- $cluster_search['netzgebiet_id'] = $this->filter_salescluster_ids;
+ $cluster_search['id'] = $this->filter_salescluster_ids;
}
$clusters = [];
foreach(ADBNetzgebietModel::search($cluster_search) as $cluster) {
diff --git a/application/Api/v1/InvestigatorApicontroller.php b/application/Api/v1/InvestigatorApicontroller.php
new file mode 100644
index 000000000..396db8eeb
--- /dev/null
+++ b/application/Api/v1/InvestigatorApicontroller.php
@@ -0,0 +1,176 @@
+addRoute("/investigator/customer/:id", "getCustomer", "GET");
+ $this->addRoute("/investigator/customer/:id/contract", "getCustomerContract", "GET");
+ $this->addRoute("/investigator/customer/:id/cpe", "getCustomerCpe", "GET");
+ $this->addRoute("/investigator/customer/:id/address", "getCustomerAddress", "GET");
+ $this->addRoute("/investigator/search/customer", "searchCustomer", "GET");
+ }
+
+ protected function authenticated()
+ {
+ $this->registerRoutes();
+ }
+
+ public function getCustomer($customerId)
+ {
+ if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
+
+ $addresses = AddressModel::search(['customer_number' => $customerId]);
+ if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
+
+ $address = $addresses[0];
+ return mfResponse::Ok([
+ 'customer' => [
+ 'id' => $address->id,
+ 'customerNumber' => $address->customer_number,
+ 'company' => $address->company,
+ 'firstName' => $address->firstname,
+ 'lastName' => $address->lastname,
+ 'email' => $address->email,
+ 'phone' => $address->phone,
+ 'street' => $address->street,
+ 'zip' => $address->zip,
+ 'city' => $address->city,
+ 'country' => $address->country,
+ ]
+ ]);
+ }
+
+ public function getCustomerContract($customerId)
+ {
+ if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
+
+ $addresses = AddressModel::search(['customer_number' => $customerId]);
+ if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
+
+ $contracts = ContractModel::search(['owner_id' => $addresses[0]->id]);
+ $contractData = [];
+
+ foreach ($contracts as $contract) {
+ $contractData[] = [
+ 'contractId' => $contract->contract_id,
+ 'productName' => $contract->product_name,
+ 'productExternal' => $contract->product_external,
+ 'price' => $contract->price,
+ 'billingPeriod' => $contract->billing_period,
+ 'orderDate' => $contract->order_date,
+ 'finishDate' => $contract->finish_date,
+ 'cancelDate' => $contract->cancel_date,
+ 'slaId' => $contract->sla_id,
+ 'status' => $contract->cancel_date ? 'Cancelled' : ($contract->finish_date ? 'Active' : 'Pending'),
+ ];
+ }
+
+ return mfResponse::Ok([
+ 'customerId' => $customerId,
+ 'contractCount' => count($contractData),
+ 'contracts' => $contractData,
+ ]);
+ }
+
+ public function getCustomerCpe($customerId)
+ {
+ if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
+
+ $addresses = AddressModel::search(['customer_number' => $customerId]);
+ if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
+
+ $db = $this->db();
+ $sql = "SELECT cp.* FROM Cpeprovisioning cp
+ INNER JOIN `Order` o ON cp.order_id = o.id
+ WHERE o.owner_id = " . intval($addresses[0]->id) . "
+ ORDER BY cp.id DESC LIMIT 10";
+
+ $res = $db->query($sql);
+ $cpeData = [];
+
+ while ($row = $db->fetch_object($res)) {
+ $cpeData[] = [
+ 'orderId' => $row->order_id ?? null,
+ 'routerType' => $row->routertype ?? null,
+ 'routerConfigFinished' => (bool)($row->routerconfig_finished ?? false),
+ 'mac' => $row->mac ?? null,
+ 'ontSerial' => $row->ont_sn ?? null,
+ 'wifiSsid' => $row->wifi_ssid ?? null,
+ 'wifiPasswordSet' => !empty($row->wifi_pass),
+ 'vlanPublic' => $row->vlan_public ?? null,
+ 'vlanNat' => $row->vlan_nat ?? null,
+ 'vlanIpv6' => $row->vlan_ipv6 ?? null,
+ 'shipping' => (bool)($row->shipping ?? false),
+ ];
+ }
+
+ return mfResponse::Ok([
+ 'customerId' => $customerId,
+ 'cpeCount' => count($cpeData),
+ 'cpeProvisioning' => $cpeData,
+ ]);
+ }
+
+ public function getCustomerAddress($customerId)
+ {
+ if (!$customerId) return mfResponse::BadRequest(['message' => 'Customer ID is required']);
+
+ $addresses = AddressModel::search(['customer_number' => $customerId]);
+ if (empty($addresses)) return mfResponse::NotFound(['message' => 'Customer not found']);
+
+ $address = $addresses[0];
+ return mfResponse::Ok([
+ 'customerId' => $customerId,
+ 'address' => [
+ 'company' => $address->company,
+ 'name' => trim(($address->firstname ?? '') . ' ' . ($address->lastname ?? '')),
+ 'street' => $address->street,
+ 'zip' => $address->zip,
+ 'city' => $address->city,
+ 'country' => $address->country,
+ 'email' => $address->email,
+ 'phone' => $address->phone,
+ ]
+ ]);
+ }
+
+ public function searchCustomer()
+ {
+ $searchTerm = $this->get['q'] ?? '';
+ $limit = intval($this->get['limit'] ?? 10);
+
+ if (empty($searchTerm)) return mfResponse::BadRequest(['message' => 'Search term required']);
+
+ $db = $this->db();
+ $searchTerm = $db->escape($searchTerm);
+
+ $sql = "SELECT * FROM Address
+ WHERE customer_number LIKE '%$searchTerm%'
+ OR CONCAT(firstname, ' ', lastname) LIKE '%$searchTerm%'
+ OR company LIKE '%$searchTerm%'
+ OR email LIKE '%$searchTerm%'
+ OR street LIKE '%$searchTerm%'
+ LIMIT $limit";
+
+ $results = $db->queryRows($sql);
+ $customers = [];
+
+ foreach ($results as $row) {
+ $customers[] = [
+ 'customerId' => $row['customer_number'],
+ 'name' => trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: $row['company'],
+ 'email' => $row['email'],
+ 'city' => $row['city'],
+ ];
+ }
+
+ return mfResponse::Ok([
+ 'searchTerm' => $searchTerm,
+ 'count' => count($customers),
+ 'customers' => $customers,
+ ]);
+ }
+}
diff --git a/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php b/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php
index 75bdbade4..d8434a062 100644
--- a/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php
+++ b/application/Api/v1/Modules/Operationaldata/SnoppCitycom.php
@@ -85,13 +85,13 @@ class SnoppCitycom extends Modules\ApiControllerModule
$bb_down = $this->post["bb_down"];
$execution_date = false;
- if($this->post["execution_date"]) {
+ /*if($this->post["execution_date"]) {
try {
$execution_date = new \DateTime($this->post["execution_date"]);
} catch(\Exception $e) {
return \mfResponse::BadRequest(["message" => "Invalid Timestamp format"]);
}
- }
+ }*/
if(!is_numeric($bb_down) || !$bb_down || !is_numeric($bb_up) || !$bb_up || !$bb_down > 10000 || $bb_up > 10000) {
@@ -118,6 +118,10 @@ class SnoppCitycom extends Modules\ApiControllerModule
// if all services are ordered and active, finish order and return active
$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
+ if(!$ctag) {
+ return \mfResponse::NotFound(["message" => "Home not found"]);
+ }
+
if($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500);
@@ -128,11 +132,11 @@ class SnoppCitycom extends Modules\ApiControllerModule
// Home must have Status 300, else return deferred
- if($wohneinheit->status->code < 300) {
+ /*if($wohneinheit->status->code < 300) {
return \mfResponse::Ok(["message" => "ONT not yet installed. Deferred", "activation_status" => "deferred"]);
- }
-
+ }*/
+ /*
$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
$data["up"] = $bb_up;
$data["down"] = $bb_down;
@@ -159,10 +163,30 @@ class SnoppCitycom extends Modules\ApiControllerModule
// order Service at Citycom and set Preorder to 500 Finished
if(!$cc_api->orderServices($preorder, $cc_home_id, $data)) {
return \mfResponse::InternalServerError(["message" => "Error activating service"]);
+ }*/
+
+ // update product at citycom
+ //$cc_home_id = \Citycom_OanApiHelper::hausnummerExtrefToCitycomId($wohneinheit->extref);
+ $data["up"] = $bb_up;
+ $data["down"] = $bb_down;
+ $data["product_name"] = false;
+
+ if($preorder->campaign->fulfillment == "citycom_oan") {
+ $data["product_name"] = "Estmk Greenstream OAN $bb_down/$bb_up";
}
- // live check if service is active, if not return deferred
- $ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
+ $cc_api_client = new \Citycom_OanApiClient(CITYCOM_OAN_API_USER, CITYCOM_OAN_API_PASS);
+ $cc_api = new \Citycom_OanApiHelper($cc_api_client);
+
+ // try to update product with bandwidth provided by snopp.
+ // updateService() only updates if values are changed.
+ if(!$cc_api->updateService($ctag->ext_id, $data)) {
+ $this->log->error(__METHOD__.": Error updating service {$ctag->ext_id} for preorder {$preorder->id}");
+ //return \mfResponse::InternalServerError(["message" => "Error activating service"]);
+ }
+
+ // check if service is active, if not return deferred
+ //$ctag = PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => "inet"]);
if($ctag->ext_id && $ctag->ext_status == "finished") {
if($preorder->status->code < 500) {
$preorder->setNewStatusCode(500);
diff --git a/application/Api/v1/Modules/Preorder/Cif.php b/application/Api/v1/Modules/Preorder/Cif.php
index 8e60be040..6a34e818f 100644
--- a/application/Api/v1/Modules/Preorder/Cif.php
+++ b/application/Api/v1/Modules/Preorder/Cif.php
@@ -43,22 +43,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Preorder not found"]);
}
- // set status to 200
- if($preorder->status->code < 200) {
- $new_status = \PreorderstatusModel::getFirst(["code" => 200]);
- if(!$new_status) {
- return \mfResponse::InternalServerError();
- }
- $preorder->status_id = $new_status->id;
- $preorder->save();
- }
-
- $sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
- $sflag->preorder_id = $preorder->id;
- if(!$sflag->value->value) {
- $sflag->value->value = 1;
- $sflag->value->save();
- }
+ // set status flag 200
+ $preorder->setStatusFlag(200, 1);
return \mfResponse::Ok(["message" => "Status successfully updated."]);
@@ -134,22 +120,8 @@ class Cif extends Modules\ApiControllerModule {
return \mfResponse::NotFound(["message" => "Invalid ciftoken"]);
}
- // set status to 200
- if($preorder->status->code < 200) {
- $new_status = \PreorderstatusModel::getFirst(["code" => 200]);
- if(!$new_status) {
- return \mfResponse::InternalServerError();
- }
- $preorder->status_id = $new_status->id;
- $preorder->save();
- }
-
- $sflag = \PreorderStatusflagModel::getFirst(["code" => 200]);
- $sflag->preorder_id = $preorder->id;
- if(!$sflag->value->value) {
- $sflag->value->value = 1;
- $sflag->value->save();
- }
+ // set status flag 200
+ $preorder->setStatusFlag(200, 1);
return \mfResponse::Ok(["message" => "Status successfully updated."]);
diff --git a/application/AssetManagement/AssetManagementController.php b/application/AssetManagement/AssetManagementController.php
index 9db10b068..ff7d00cc6 100644
--- a/application/AssetManagement/AssetManagementController.php
+++ b/application/AssetManagement/AssetManagementController.php
@@ -7,7 +7,8 @@ class AssetManagementController extends TTCrud
// Simplified columns for better layout, details are in the 'assetDetails' slot
protected array $columns = [
- ['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search']],
+ ['key' => 'assetDetails', 'text' => 'Gerät', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
+ ['key' => 'category', 'text' => 'Kategorie', 'modal' => false, 'table' => ['filter' => 'select', 'filterOptions' => []]],
['key' => 'currentUser', 'text' => 'Status', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
['key' => 'location', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'text'], 'table' => ['filter' => 'search']],
['key' => 'serviceDueDate', 'text' => 'Service fällig', 'required' => false, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
@@ -22,6 +23,15 @@ class AssetManagementController extends TTCrud
$this->additionalJSVariables['ASSET_ADMIN'] = '0';
$this->columns = array_filter($this->columns, fn($col) => $col['key'] !== 'actions');
}
+
+ $categories = AssetManagementModel::getDistinctCategories();
+ $categoryOptions = array_map(fn($cat) => ['value' => $cat, 'text' => $cat], $categories);
+ foreach ($this->columns as &$column) {
+ if ($column['key'] === 'category') {
+ $column['table']['filterOptions'] = $categoryOptions;
+ break;
+ }
+ }
}
/**
@@ -42,7 +52,12 @@ class AssetManagementController extends TTCrud
$json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? [];
- $order = $json['order'] ?? ['key' => 'id', 'order' => 'DESC'];
+ $order = $json['order'] ?? ['key' => 'name', 'order' => 'ASC'];
+
+ // Map virtual column 'assetDetails' to actual 'name' column for sorting
+ if (isset($order['key']) && $order['key'] === 'assetDetails') {
+ $order['key'] = 'name';
+ }
// Fetch paginated assets
$assets = AssetManagementModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
@@ -277,6 +292,18 @@ class AssetManagementController extends TTCrud
self::returnJson(['success' => true, 'message' => 'Reservierung gelöscht.']);
}
+ protected function getCategoriesAction()
+ {
+ $searchTerm = $this->request->q ?? '';
+ $categories = AssetManagementModel::getDistinctCategories($searchTerm);
+
+ $result = array_map(function($category) {
+ return ['value' => $category, 'text' => $category];
+ }, $categories);
+
+ self::returnJson($result);
+ }
+
protected function printLabelAction() {
if (!$this->user->can('AssetAdmin')) {
self::sendError("Permission denied", 403);
diff --git a/application/AssetManagement/AssetManagementModel.php b/application/AssetManagement/AssetManagementModel.php
index 9cef32f94..793e54f10 100644
--- a/application/AssetManagement/AssetManagementModel.php
+++ b/application/AssetManagement/AssetManagementModel.php
@@ -4,6 +4,7 @@ class AssetManagementModel extends TTCrudBaseModel {
public int $id;
public string $name;
public ?string $description;
+ public ?string $category;
public ?int $mainImageId; // Renamed from imageId
public ?string $imageIds; // Changed to JSON (will be a string in PHP)
public string $assetNumber;
@@ -35,8 +36,7 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) {
if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term);
- // For each term, search in name, assetNumber, and description.
- $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
+ $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
}
if (!empty($searchConditions)) {
@@ -99,8 +99,8 @@ class AssetManagementModel extends TTCrudBaseModel {
foreach ($searchTerms as $term) {
if (empty(trim($term))) continue;
$escapedTerm = $db->real_escape_string($term);
- // For each term, search in name, assetNumber, and description.
- $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%')";
+ // For each term, search in name, assetNumber, description, and category.
+ $searchConditions[] = "(`name` LIKE '%$escapedTerm%' OR `assetNumber` LIKE '%$escapedTerm%' OR `description` LIKE '%$escapedTerm%' OR `category` LIKE '%$escapedTerm%')";
}
if (!empty($searchConditions)) {
@@ -128,4 +128,26 @@ class AssetManagementModel extends TTCrudBaseModel {
return $result->fetch_assoc()['count'];
}
+
+ public static function getDistinctCategories(string $searchTerm = ''): array {
+ $db = self::getDB();
+ $table = self::getFullyQualifiedTable();
+
+ $sql = "SELECT DISTINCT `category` FROM $table WHERE `category` IS NOT NULL AND `category` != ''";
+
+ if (!empty($searchTerm)) {
+ $escapedTerm = $db->real_escape_string($searchTerm);
+ $sql .= " AND `category` LIKE '%$escapedTerm%'";
+ }
+
+ $sql .= " ORDER BY `category` ASC LIMIT 20";
+
+ $result = $db->query($sql);
+ $categories = [];
+ while ($row = $result->fetch_assoc()) {
+ $categories[] = $row['category'];
+ }
+
+ return $categories;
+ }
}
\ No newline at end of file
diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php
index f66ed1e8d..a55928198 100644
--- a/application/Cpeprovisioning/CpeprovisioningController.php
+++ b/application/Cpeprovisioning/CpeprovisioningController.php
@@ -549,23 +549,23 @@ class CpeprovisioningController extends mfBaseController {
"ORDER_URL" => $this->getUrl("Order"),
"NETWORKS" => NetworkModel::getAll(),
"ROUTER_OPTIONS" => [
+ ['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
+ ['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
+ ['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
+ ['value' => 'FritzBox 6670 Cable', 'text' => 'FritzBox 6670 Cable (Inet, Phone, IPTV)'],
// General Options
['value' => 'eigener Router', 'text' => 'Eigener Router'],
['value' => 'anderes CPE', 'text' => 'Anderes CPE'],
- // PPPoE/DHCP Routers
- ['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
- ['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
- ['value' => 'FritzBox 4050', 'text' => 'FritzBox 4050 (Inet, Phone IPTV)'],
- ['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
- ['value' => 'FritzBox 7530', 'text' => 'FritzBox 7530 (Inet, Phone, IPTV)'],
- ['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
- ['value' => 'FritzBox 7690', 'text' => 'FritzBox 7690 (Inet, Phone, IPTV)'],
// Static Routers
['value' => 'Mikrotik HAP AC', 'text' => 'Mikrotik HAP AC (Inet, IPTV)'],
['value' => 'Mikrotik HEX S', 'text' => 'Mikrotik HEX S (Inet, IPTV)'],
['value' => 'Mikrotik RB3011', 'text' => 'Mikrotik RB3011 (Inet, IPTV)'],
- // CMTS Routers
+ // Legacy
['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'],
+ ['value' => 'FritzBox 4040', 'text' => 'FritzBox 4040 (Inet, IPTV)'],
+ ['value' => 'FritzBox 5530', 'text' => 'FritzBox 5530 (Inet FiberP2P, Phone, IPTV)'],
+ ['value' => 'FritzBox 7590', 'text' => 'FritzBox 7590 (Inet, Phone, IPTV)'],
+ ['value' => 'TP-Link Archer C80', 'text' => 'TP-Link Archer C80 (Inet, IPTV)'],
],
"ROUTER_SHIPPING_DATA" => [
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8],
diff --git a/application/Emailnotification/Emailnotification.php b/application/Emailnotification/Emailnotification.php
index a686a9067..4906b0ca6 100644
--- a/application/Emailnotification/Emailnotification.php
+++ b/application/Emailnotification/Emailnotification.php
@@ -32,7 +32,7 @@ class Emailnotification {
if($object_data !== false) $this->object_data = $object_data;
}
- public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64" , $charset = "utf-8") {
+ public function addAttachment($filepath = null, $content = null, $name = false, $c_type = "application/octet-stream", $disposition = "attachment", $encoding = "base64", $charset = "utf-8") {
$attachment = [
"file" => $filepath,
"content" => $content,
diff --git a/application/Invoice/InvoiceController.php b/application/Invoice/InvoiceController.php
index 6d44febed..3cbc18800 100644
--- a/application/Invoice/InvoiceController.php
+++ b/application/Invoice/InvoiceController.php
@@ -681,6 +681,8 @@ class InvoiceController extends mfBaseController {
}
// save Invoiceposition
+ // first round price
+ $position->price_gross = round($position->price_gross, 4);
if (!$position->save()) {
$invoice->rollbackTransaction();
die("Error saving Invoiceposition");
@@ -703,7 +705,7 @@ class InvoiceController extends mfBaseController {
}
$invoice->total = $total_net;
- $invoice->total_gross = $total_gross;
+ $invoice->total_gross = round($total_gross, 4);
//$invoice->total_vat = $total_vat;
if (!$invoice->save()) {
@@ -778,6 +780,207 @@ class InvoiceController extends mfBaseController {
}
+ protected function manualExportBmd() {
+ if(!$this->me->can("Billing")) {
+ $this->redirect("Dashboard");
+ }
+ //var_dump($this->request->get());
+
+ $csv_header = "\u{FEFF}satzart;konto;gkonto;belegnr;belegdatum;zziel;skontopz;skontotage;buchsymbol;buchcode;prozent;steuercode;betrag;steuer;text;";
+ $csv_header .= "bank-iban-nr;bank-swiftcode;bank-mandatsid;bank-mandatsdatum;bank-mandatskz;bank-letztereinzug;zvsperre;bankeinzug;";
+ $csv_header .= "kost;kobetrag";
+
+ $csv_out = "";
+
+ //var_dump($filter);exit;
+ $filter = [
+ "lock" => 1,
+ "exported" => 0,
+ ];
+
+ if($this->request->manual_invoice_date_from) {
+ $date_from = Layout::dateToInt($this->request->manual_invoice_date_from);
+ if($date_from) {
+ $filter["invoice_date"] = ["from" => $date_from];
+ }
+ }
+ if($this->request->manual_invoice_date_to) {
+ $date_to = Layout::dateToInt($this->request->manual_invoice_date_to);
+ if($date_to) {
+ $filter["invoice_date"] = ["to" => $date_to];
+ }
+ }
+
+ //var_dump($filter);exit;
+
+ if(!ManualInvoiceModel::count($filter)) {
+ $this->layout()->setFlash("Keine Rechnungen zum Exportieren gefunden.");
+ $this->redirect("Invoice");
+ }
+ foreach(ManualInvoiceModel::getAll($filter) as $invoice) {
+ if($invoice->exported) {
+ die("wtf");
+ }
+
+ $billingaddress = new Address($invoice->billingaddress_id);
+ if(!$billingaddress->id) {
+ die("Billingaddresse für Rechnung {$invoice->invoice_number} not found");
+ }
+
+ $kostentraeger = [];
+ //var_dump($invoice->getProperty("positions"));
+ //$vat_total_gross = 0;
+ foreach($invoice->getProperty("positions") as $position) {
+ if(!array_key_exists($position->position_group, $kostentraeger)) {
+ $kostentraeger[$position->position_group] = 0;
+ }
+ //$kostentraeger[$position->position_group] += $position->price_gross;
+ //$vat_total_gross += $position->price_gross - $position->price_total;
+
+ $price = $position->price_total;
+ /*if($position->discount) {
+ $price -= ($price / 100) * $position->discount;
+ }*/
+ if($invoice->gesamtrabatt) {
+ $price -= ($price / 100) * $invoice->gesamtrabatt;
+ }
+
+ $kostentraeger[$position->position_group] += $price;
+ }
+
+ $total_gross = $invoice->total_gross;
+ /*if($invoice->gesamtrabatt) {
+ $total_gross -= round(($total_gross / 100) * $invoice->gesamtrabatt, 4);
+ }*/
+
+ $total = $invoice->total;
+ /*if($invoice->gesamtrabatt) {
+ $total -= round(($total / 100) * $invoice->gesamtrabatt, 4);
+ }*/
+
+ if($invoice->total_gross) {
+ $vatrate = 20;
+ }
+ if($invoice->total == $invoice->total_gross && $invoice->fibu_cost_area != "domestic") {
+ $vatrate = "0";
+ } else {
+ $vatrate = "20";
+ }
+ $vat = $total_gross - $total;
+ $vat *= -1;
+ //$vat_total_gross *= -1;
+
+ if($invoice->total < 0) {
+ $buchsymbol = "GU";
+ } else {
+ $buchsymbol = "AR";
+ }
+
+ $fibu_account = $invoice->fibu_account_number;
+
+ $buchungstext = "[".$invoice->customer_number."]";
+ if($invoice->company) {
+ $buchungstext .= " ".$invoice->company;
+ } elseif($invoice->firstname || $invoice->lastname) {
+ $buchungstext .= " ".$invoice->firstname." ".$invoice->lastname;
+ }
+
+ $buchungstext = str_replace(["\n","\r", ";"], "", $buchungstext);
+ $buchcode = "1";
+ $is_sepa = ($invoice->billing_type == "sepa");
+
+ $iban = "";
+ $bic = "";
+ $sepa_id = "";
+ $sepa_date = false;
+ $last_invoice_date = false;
+ $mandatskz = "";
+
+ if($is_sepa) {
+ $iban = $invoice->bank_account_iban;
+ $bic = $invoice->bank_account_bic;
+ $sepa_id = "R".$fibu_account;
+ if($billingaddress->sepa_date) {
+ $sepa_date = new DateTime("@".$billingaddress->sepa_date);
+ $sepa_date->setTimezone(new DateTimeZone("Europe/Vienna"));
+
+ if($billingaddress->last_invoice_date) {
+ $sepa_last_date = new DateTime("@".$billingaddress->last_invoice_date);
+
+ $data["sepa_last_date"] = $sepa_last_date->format("Y-m-d");
+
+ $last_invoice_date = new DateTime("@".$billingaddress->last_invoice_date);
+ $last_invoice_date->setTimezone(new DateTimeZone("Europe/Vienna"));
+ if($last_invoice_date->format("Y-m-d") < $sepa_date->format("Y-m-d")) {
+ $last_invoice_date = false;
+ }
+ }
+ }
+
+ $mandatskz = ($last_invoice_date ? "1" : "0");
+
+ $three_years_ago = new DateTime("now");
+ $three_years_ago->modify("-3 years");
+
+ if($mandatskz == "0") {
+ while($sepa_date->format("Y-m-d") < $three_years_ago->format("Y-m-d")) {
+ $sepa_date->modify("+1 year");
+ }
+ }
+
+ }
+
+ $kost = $invoice->fibu_cost_account;
+
+ $csv_out .= "0;";
+ $csv_out .= $fibu_account.";";
+ $csv_out .= $invoice->fibu_cost_account.";";
+ $csv_out .= $invoice->invoice_number.";";
+ $csv_out .= date("d.m.Y", $invoice->invoice_date).";";
+ $csv_out .= ($invoice->fibu_payment_due === null) ? ";" : $invoice->fibu_payment_due.";";
+ $csv_out .= ($invoice->fibu_payment_skonto) ? $invoice->fibu_payment_skonto.";" : ";";
+ $csv_out .= ($invoice->fibu_payment_skonto_rate) ? $invoice->fibu_payment_skonto_rate.";" : ";";
+ $csv_out .= $buchsymbol.";";
+ $csv_out .= $buchcode.";";
+ $csv_out .= $vatrate.";";
+ $csv_out .= $invoice->fibu_taxcode.";";
+ $csv_out .= number_format($total_gross, 2, ",", "").";";
+ $csv_out .= number_format($vat, 2, ",", "").";";
+ $csv_out .= $buchungstext.";";
+
+ $csv_out .= $iban.";";
+ $csv_out .= $bic.";";
+ $csv_out .= $sepa_id.";";
+ $csv_out .= ($sepa_date ? $sepa_date->format("d.m.Y") : "").";";
+ $csv_out .= $mandatskz.";";
+ $csv_out .= ($last_invoice_date ? $last_invoice_date->format("d.m.Y") : "").";";
+ $csv_out .= ($is_sepa ? 0 : 10).";";
+ $csv_out .= ($is_sepa ? 1 : 0);
+
+
+ if(count($kostentraeger) >= 2) {
+ foreach($kostentraeger as $kostelle => $kobetrag) {
+ $kobetrag_text = number_format($kobetrag, 2, ",", "");
+ $csv_out .= "\n1;;;;;;;;;;;;;;;;;;;;;;;$kostelle;$kobetrag_text;";
+ }
+ }
+
+ ///var_dump($kostentraeger);
+ $csv_out .= "\n";
+
+
+ }
+ //exit;
+ /*$this->layout()->setFlash("Export erfolgreich abgeschlossen", "success");
+ $this->redirect("Invoice");*/
+
+ header("Content-type: text/csv; charset=utf-8");
+ header('Content-disposition: attachment; filename="tt-mrech-export-bmd-'.date('Y-m-d_H-i-s').'.csv"');
+
+ echo $csv_header."\n".$csv_out;
+ exit;
+ }
+
protected function exportBmdAction() {
if(!$this->me->can("Billing")) {
$this->redirect("Dashboard");
diff --git a/application/ManualInvoice/ManualInvoiceController.php b/application/ManualInvoice/ManualInvoiceController.php
index caa91d659..b29cca986 100644
--- a/application/ManualInvoice/ManualInvoiceController.php
+++ b/application/ManualInvoice/ManualInvoiceController.php
@@ -105,10 +105,12 @@ class ManualInvoiceController extends TTCrud
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
- "{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "| Leistungszeitraum: | " . htmlspecialchars($invoice->leistungszeitraum) . " | " : "",
- "{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "| Externe Referenz: | " . htmlspecialchars($invoice->externe_referenz) . " | " : "",
+ "{{ leistungszeitraumHtml }}" => ($invoice->performance_period ?? '') ? "| Leistungszeitraum: | " . htmlspecialchars($invoice->performance_period) . " | " : "",
+ "{{ externeReferenzHtml }}" => ($invoice->external_reference ?? '') ? "| Externe Referenz: | " . htmlspecialchars($invoice->external_reference) . " | " : "",
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "| Ihre UID: | " . $invoice->uid . " | " : "",
- "{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
+ "{{ qrCodeHtml }}" => ($invoice->total_gross ?? 0) >= 0
+ ? ' total_gross ?? 0, 2)) . '" style="display: block; height: 100%; max-height: 3.5cm; width: auto;"> | '
+ : ''
];
$headerHtml = str_replace(array_keys($replacements), array_values($replacements), file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html"));
@@ -208,8 +210,6 @@ class ManualInvoiceController extends TTCrud
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? null;
- $subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
- $bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
@@ -222,6 +222,19 @@ class ManualInvoiceController extends TTCrud
return;
}
+ // Format invoice date for display
+ $invoiceDateFormatted = date('d.m.Y', $invoice->invoice_date);
+
+ // Set default subject and body with invoice number and date
+ $defaultSubject = "Ihre Rechnung {$invoice->invoice_number} vom {$invoiceDateFormatted}";
+ $defaultBody = "Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung Nr. {$invoice->invoice_number} vom {$invoiceDateFormatted}.\n\nMit freundlichen Grüßen\nIhr XINON Team";
+
+ $subject = $post['subject'] ?? $defaultSubject;
+ $bodyText = $post['body'] ?? $defaultBody;
+
+ // Convert literal \n strings to actual newlines (in case frontend sends escaped strings)
+ $bodyText = str_replace('\n', "\n", $bodyText);
+
// Generate PDF
$pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) {
@@ -232,19 +245,33 @@ class ManualInvoiceController extends TTCrud
$pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation ---
- $logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
- $logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath);
- // Construct HTML Body
- $html = 'Rechnung';
- $html .= '';
+ // Construct HTML Body with Outlook compatibility
+ $html = '';
+ $html .= '';
+ $html .= '';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' Rechnung';
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= '';
- // Logos
- $html .= ' ';
- if ($logoToolExists) $html .= '  ';
- if ($logoXinonExists) $html .= '  ';
+ // Outlook-safe container table
+ $html .= '';
+ $html .= ' ';
+
+ // Logo with Outlook-safe sizing
+ $html .= ' ';
+ if ($logoXinonExists) {
+ $html .= '';
+ $html .= '  ';
+ $html .= '';
+ }
$html .= ' ';
$html .= ' ' . htmlspecialchars($subject) . '';
@@ -254,7 +281,9 @@ class ManualInvoiceController extends TTCrud
$html .= ' ';
+ $html .= ' ';
+ $html .= '';
+ $html .= '';
$mail = new PHPMailer(true);
try {
@@ -269,12 +298,11 @@ class ManualInvoiceController extends TTCrud
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
- // Logos
- if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
+ // Logo embedding
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
- $mail->setFrom('thetool@xinon.at', 'XINON TheTool');
+ $mail->setFrom('thetool@xinon.at', 'XINON GmbH - Rechnungswesen');
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName);
@@ -283,7 +311,10 @@ class ManualInvoiceController extends TTCrud
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
- $mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
+ // Attachment filename: YYYY-MM-DD_InvoiceNumber_Rechnung.pdf
+ $invoiceDateFile = date('Y-m-d', $invoice->invoice_date);
+ $attachmentFilename = "{$invoiceDateFile}_{$invoice->invoice_number}_Rechnung.pdf";
+ $mail->addStringAttachment($pdfContent, $attachmentFilename, 'base64', 'application/pdf');
$mail->send();
@@ -320,10 +351,17 @@ class ManualInvoiceController extends TTCrud
$me = new User();
$me->loadMe();
- // Log download in journal
+ // Update status to 'gesendet' (same as email)
+ if ($invoice->status === 'erstellt') {
+ $invoice->status = 'gesendet';
+ $invoice->save();
+ }
+
+ // Log download in journal with status change
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'Rechnung heruntergeladen',
+ 'statusChange' => 'gesendet',
'createBy' => $me->id,
'create' => time()
]);
@@ -349,20 +387,42 @@ class ManualInvoiceController extends TTCrud
$data['invoice_date'] = strtotime($data['invoice_date']);
}
- $data = array_merge([
- 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
- 'invoice_date' => $data['invoice_date'] ?? time(),
- 'status' => 'erstellt',
- 'fibu_payment_skonto' => 0,
- 'fibu_payment_skonto_rate' => 0,
- 'gesamtrabatt' => 0,
- 'total' => 0,
- 'total_gross' => 0,
- 'create_by' => $me->id,
- 'edit_by' => $me->id,
- 'create' => time(),
- 'edit' => time()
- ], $data);
+ // Always generate invoice number (override any null from frontend)
+ $data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
+ $data['invoice_date'] = $data['invoice_date'] ?? time();
+ $data['status'] = 'erstellt';
+ $data['fibu_payment_skonto'] = $data['fibu_payment_skonto'] ?? 0;
+ $data['fibu_payment_skonto_rate'] = $data['fibu_payment_skonto_rate'] ?? 0;
+
+ $data['total_discount'] = $data['total_discount'] ?? $data['gesamtrabatt'] ?? 0;
+ $data['performance_period'] = $data['performance_period'] ?? $data['leistungszeitraum'] ?? null;
+ $data['introductory_text'] = $data['introductory_text'] ?? $data['einleitender_text'] ?? null;
+ $data['external_reference'] = $data['external_reference'] ?? $data['externe_referenz'] ?? null;
+ unset($data['gesamtrabatt'], $data['leistungszeitraum'], $data['einleitender_text'], $data['externe_referenz'], $data['billing_delivery']);
+
+ $data['total'] = $data['total'] ?? 0;
+ $data['total_gross'] = $data['total_gross'] ?? 0;
+ $data['lock'] = 0;
+ $data['exported'] = 0;
+
+ if (($data['billing_type'] ?? '') === 'sepa' && ($data['billingaddress_id'] ?? null)) {
+ $address = new Address($data['billingaddress_id']);
+ if ($address->id) {
+ $data['bank_account_bank'] = $address->bank_account_bank;
+ $data['bank_account_owner'] = $address->bank_account_owner;
+ $data['bank_account_iban'] = str_replace(' ', '', $address->bank_account_iban ?? '');
+ $data['bank_account_bic'] = str_replace(' ', '', $address->bank_account_bic ?? '');
+ if ($address->sepa_date) {
+ $data['sepa_date'] = date('Y-m-d', $address->sepa_date);
+ }
+ $data['sepa_id'] = 'R' . ($data['fibu_account_number'] ?? '');
+ }
+ }
+
+ $data['create_by'] = $me->id;
+ $data['edit_by'] = $me->id;
+ $data['create'] = time();
+ $data['edit'] = time();
return true;
}
@@ -389,9 +449,15 @@ class ManualInvoiceController extends TTCrud
unset($data['positions']);
}
- if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
- $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
- return false;
+ if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id']))) {
+ if ($invoice->lock == 1) {
+ $this->infoMessages['update'] = 'Rechnung ist gesperrt und kann nicht bearbeitet werden';
+ return false;
+ }
+ if ($invoice->status === 'exportiert') {
+ $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
+ return false;
+ }
}
// Convert invoice_date from string to timestamp if needed
@@ -432,23 +498,39 @@ class ManualInvoiceController extends TTCrud
$me->loadMe();
foreach ($this->tempPositions as $position) {
- // Skip empty positions
- if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
+ $articleName = $position['warehousearticle_name'] ?? $position['product_name'] ?? '';
+ if (empty($articleName) || ($position['amount'] ?? 0) == 0) continue;
- // Map _group to position_group
- $groupName = $position['_group'] ?? null;
- unset($position['_group']);
+ $amount = floatval($position['amount']);
+ $price = floatval($position['price']);
+ $discount = floatval($position['discount'] ?? 0);
+ $vatrate = floatval($position['vatrate'] ?? 0);
+ $priceAfterDiscount = $amount * $price * (1 - $discount / 100);
+ $priceGross = $priceAfterDiscount * (1 + $vatrate / 100);
- ManualInvoicepositionModel::create(array_merge([
+ ManualInvoicepositionModel::create([
'manualinvoice_id' => $invoiceId,
- 'position_group' => $groupName,
- 'unit' => 'Stk.',
- 'discount' => 0,
+ 'position_group' => $position['_group'] ?? null,
+ 'matchcode' => $position['matchcode'] ?? null,
+ 'warehousearticle_id' => $position['warehousearticle_id'] ?? $position['product_id'] ?? 0,
+ 'warehousearticle_name' => $articleName,
+ 'product_info' => $position['product_info'] ?? '',
+ 'amount' => $amount,
+ 'unit' => $position['unit'] ?? 'Stk.',
+ 'price' => $price,
+ 'discount' => $discount,
+ 'price_total' => $priceAfterDiscount,
+ 'price_gross' => $priceGross,
+ 'vatrate' => $vatrate,
+ 'fibu_cost_account' => $position['fibu_cost_account'] ?? null,
+ 'fibu_cost_account_legacy' => $position['fibu_cost_account_legacy'] ?? null,
+ 'fibu_taxcode' => $position['fibu_taxcode'] ?? null,
+ 'options' => $position['options'] ?? null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
'edit' => time()
- ], $position));
+ ]);
}
$this->tempPositions = [];
}
@@ -458,17 +540,13 @@ class ManualInvoiceController extends TTCrud
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
$subtotal = array_sum(array_column($positions, 'price_total'));
+ $totalDiscount = $invoice->total_discount ?? 0;
+ $netTotal = $subtotal * (1 - $totalDiscount / 100);
- // Apply gesamtrabatt (total discount) if exists
- $gesamtrabatt = $invoice->gesamtrabatt ?? 0;
- $discountAmount = $subtotal * ($gesamtrabatt / 100);
- $netTotal = $subtotal - $discountAmount;
-
- // Calculate gross total with VAT applied after discount
$grossTotal = 0;
foreach ($positions as $pos) {
$positionNet = $pos->price_total;
- $positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
+ $positionAfterDiscount = $positionNet * (1 - $totalDiscount / 100);
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
}
@@ -530,11 +608,11 @@ class ManualInvoiceController extends TTCrud
'id' => $pos->id,
'manualinvoice_id' => $pos->manualinvoice_id,
'_group' => $pos->position_group ?? '',
- 'billing_id' => $pos->billing_id,
- 'contract_id' => $pos->contract_id,
'matchcode' => $pos->matchcode,
- 'product_id' => $pos->product_id,
- 'product_name' => $pos->product_name,
+ 'warehousearticle_id' => $pos->warehousearticle_id,
+ 'warehousearticle_name' => $pos->warehousearticle_name,
+ 'product_id' => $pos->warehousearticle_id,
+ 'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info,
'amount' => $pos->amount,
'unit' => $pos->unit ?? 'Stk.',
@@ -580,19 +658,20 @@ class ManualInvoiceController extends TTCrud
foreach ($existingCredits as $credit) {
foreach ($credit->getProperty('positions') as $creditPos) {
- $key = $creditPos->product_id . '_' . $creditPos->matchcode;
+ $key = $creditPos->warehousearticle_id . '_' . $creditPos->matchcode;
$creditedAmounts[$key] = ($creditedAmounts[$key] ?? 0) + abs($creditPos->amount);
}
}
$availablePositions = [];
foreach ($positions as $pos) {
- $key = $pos->product_id . '_' . $pos->matchcode;
+ $key = $pos->warehousearticle_id . '_' . $pos->matchcode;
$availableAmount = $pos->amount - ($creditedAmounts[$key] ?? 0);
if ($availableAmount > 0) {
$availablePositions[] = [
'id' => $pos->id,
- 'product_name' => $pos->product_name,
+ 'warehousearticle_name' => $pos->warehousearticle_name,
+ 'product_name' => $pos->warehousearticle_name,
'product_info' => $pos->product_info,
'original_amount' => $pos->amount,
'credited_amount' => $creditedAmounts[$key] ?? 0,
@@ -600,7 +679,8 @@ class ManualInvoiceController extends TTCrud
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'vatrate' => $pos->vatrate,
- 'product_id' => $pos->product_id,
+ 'warehousearticle_id' => $pos->warehousearticle_id,
+ 'product_id' => $pos->warehousearticle_id,
'matchcode' => $pos->matchcode,
'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_taxcode' => $pos->fibu_taxcode
@@ -626,6 +706,12 @@ class ManualInvoiceController extends TTCrud
if (!$originalInvoiceId || empty($positions) || !($originalInvoice = ManualInvoiceModel::get($originalInvoiceId))) {
self::returnJson(['success' => false, 'message' => 'Ungültige Anfrage']);
+ return;
+ }
+
+ if ($originalInvoice->lock == 1) {
+ self::returnJson(['success' => false, 'message' => 'Originalrechnung ist gesperrt und kann nicht gutgeschrieben werden']);
+ return;
}
$me = new User();
@@ -634,10 +720,10 @@ class ManualInvoiceController extends TTCrud
$invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(),
- 'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
- 'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
- 'externe_referenz' => $originalInvoice->externe_referenz ?? null,
- 'gesamtrabatt' => 0,
+ 'performance_period' => $originalInvoice->performance_period ?? null,
+ 'introductory_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
+ 'external_reference' => $originalInvoice->external_reference ?? null,
+ 'total_discount' => 0,
'owner_id' => $originalInvoice->owner_id,
'billingaddress_id' => $originalInvoice->billingaddress_id,
'customer_number' => $originalInvoice->customer_number,
@@ -663,7 +749,6 @@ class ManualInvoiceController extends TTCrud
'email' => $originalInvoice->email,
'uid' => $originalInvoice->uid,
'billing_type' => $originalInvoice->billing_type,
- 'billing_delivery' => $originalInvoice->billing_delivery,
'bank_account_bank' => $originalInvoice->bank_account_bank,
'bank_account_owner' => $originalInvoice->bank_account_owner,
'bank_account_iban' => $originalInvoice->bank_account_iban,
@@ -673,6 +758,8 @@ class ManualInvoiceController extends TTCrud
'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'erstellt',
+ 'lock' => 0,
+ 'exported' => 0,
'create' => time(),
'edit' => time(),
'create_by' => $me->id,
@@ -681,6 +768,7 @@ class ManualInvoiceController extends TTCrud
if (!($creditInvoiceId = ManualInvoiceModel::create($invoiceData))) {
self::returnJson(['success' => false, 'message' => 'Fehler beim Erstellen der Gutschrift']);
+ return;
}
foreach ($positions as $pos) {
@@ -688,8 +776,8 @@ class ManualInvoiceController extends TTCrud
ManualInvoicepositionModel::create([
'manualinvoice_id' => $creditInvoiceId,
'position_group' => null,
- 'product_id' => $pos['product_id'],
- 'product_name' => $pos['product_name'],
+ 'warehousearticle_id' => $pos['warehousearticle_id'] ?? $pos['product_id'] ?? 0,
+ 'warehousearticle_name' => $pos['warehousearticle_name'] ?? $pos['product_name'] ?? '',
'product_info' => $pos['product_info'] ?? '',
'amount' => -abs($pos['amount']),
'unit' => $pos['unit'] ?? 'Stk.',
@@ -701,8 +789,6 @@ class ManualInvoiceController extends TTCrud
'matchcode' => $pos['matchcode'] ?? null,
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
- 'contract_id' => 0,
- 'billing_id' => null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
@@ -718,7 +804,11 @@ class ManualInvoiceController extends TTCrud
protected function beforeDelete(): bool {
if ($id = $this->request->id) {
$invoice = ManualInvoiceModel::get($id);
- if ($invoice && $invoice->status === 'exported') {
+ if ($invoice && $invoice->lock == 1) {
+ $this->infoMessages['delete'] = 'Rechnung ist gesperrt und kann nicht gelöscht werden';
+ return false;
+ }
+ if ($invoice && ($invoice->status === 'exported' || $invoice->status === 'exportiert')) {
$this->infoMessages['delete'] = 'Rechnung wurde bereits exportiert und kann nicht gelöscht werden';
return false;
}
@@ -732,4 +822,119 @@ class ManualInvoiceController extends TTCrud
}
return true;
}
+
+ protected function getArticleVatInfoAction() {
+ $articleId = $_GET['article_id'] ?? null;
+ $vatarea = $_GET['vatarea'] ?? 'domestic';
+
+ if (!$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Article ID required']);
+ return;
+ }
+
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Article not found']);
+ return;
+ }
+
+ $vatgroupId = $article->vatgroup_id;
+ $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
+
+ if (!$vatrate) {
+ self::returnJson(['success' => false, 'message' => 'Vatrate not found for vatgroup ' . $vatgroupId . ' and area ' . $vatarea]);
+ return;
+ }
+
+ $prices = [];
+ if (!empty($article->cheapestSellPrice)) {
+ $pricesData = json_decode($article->cheapestSellPrice, true);
+ if (is_array($pricesData)) {
+ $prices = $pricesData;
+ }
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'article' => [
+ 'id' => $article->id,
+ 'title' => $article->title,
+ 'articleNumber' => $article->articleNumber,
+ 'description' => $article->description,
+ 'vatgroup_id' => $article->vatgroup_id,
+ 'unit' => $article->unit
+ ],
+ 'prices' => $prices,
+ 'vatgroup_id' => $vatgroupId,
+ 'fibu_cost_account' => $vatrate->account,
+ 'fibu_cost_account_legacy' => $vatrate->legacy_account,
+ 'fibu_taxcode' => $vatrate->taxcode,
+ 'vatrate' => $vatrate->rate
+ ]);
+ }
+
+ protected function getCustomerBillingInfoAction() {
+ $addressId = $_GET['address_id'] ?? null;
+ $vatgroupId = $_GET['vatgroup_id'] ?? 2;
+
+ if (!$addressId) {
+ self::returnJson(['success' => false, 'message' => 'Address ID required']);
+ return;
+ }
+
+ $address = new Address($addressId);
+ if (!$address->id) {
+ self::returnJson(['success' => false, 'message' => 'Address not found']);
+ return;
+ }
+
+ $vatarea = 'domestic';
+ if ($address->country_id) {
+ $country = new Country($address->country_id);
+ if ($country->id && $country->isocode != TT_HOMECOUNTRY_ISOCODE) {
+ $vatarea = $country->is_eu ? 'eu' : 'other';
+ }
+ }
+
+ if ($address->uid && substr(strtolower(preg_replace('/[^a-z0-9]/i', '', $address->uid)), 0, 3) == 'atu') {
+ $vatarea = 'domestic';
+ }
+
+ $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
+ $taxText = $vatrate ? $vatrate->invoice_text : '';
+
+ $db = $this->db();
+ $sepaLimit = null;
+ $res = $db->query("SELECT manual_invoice_sepa_limit FROM Address WHERE id = " . intval($addressId));
+ if ($res && $row = $res->fetch_assoc()) {
+ $sepaLimit = $row['manual_invoice_sepa_limit'] ? floatval($row['manual_invoice_sepa_limit']) : null;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'billing_type' => $address->billing_type ?: 'invoice',
+ 'manual_invoice_sepa_limit' => $sepaLimit,
+ 'vatarea' => $vatarea,
+ 'tax_text' => $taxText,
+ 'bank_account_bank' => $address->bank_account_bank,
+ 'bank_account_owner' => $address->bank_account_owner,
+ 'bank_account_iban' => $address->bank_account_iban,
+ 'bank_account_bic' => $address->bank_account_bic,
+ 'sepa_date' => $address->sepa_date,
+ 'sepa_id' => $address->sepa_id
+ ]);
+ }
+
+ protected function getTaxTextAction() {
+ $vatgroupId = $_GET['vatgroup_id'] ?? 2;
+ $vatarea = $_GET['vatarea'] ?? 'domestic';
+
+ $vatrate = VatrateModel::getFirst(['vatgroup_id' => $vatgroupId, 'area' => $vatarea]);
+
+ self::returnJson([
+ 'success' => true,
+ 'tax_text' => $vatrate ? $vatrate->invoice_text : '',
+ 'vatrate' => $vatrate ? $vatrate->rate : 20
+ ]);
+ }
}
\ No newline at end of file
diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php
index 77408527e..e92102a85 100644
--- a/application/ManualInvoice/ManualInvoiceModel.php
+++ b/application/ManualInvoice/ManualInvoiceModel.php
@@ -4,10 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public int $id;
public ?string $invoice_number;
public int $invoice_date;
- public ?string $leistungszeitraum;
- public ?string $einleitender_text;
- public ?string $externe_referenz;
- public float $gesamtrabatt;
+ public ?string $performance_period;
+ public ?string $introductory_text;
+ public ?string $external_reference;
+ public float $total_discount;
public int $owner_id;
public int $billingaddress_id;
public int $customer_number;
@@ -33,7 +33,6 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?string $email;
public ?string $uid;
public string $billing_type;
- public string $billing_delivery;
public ?string $bank_account_bank;
public ?string $bank_account_owner;
public ?string $bank_account_iban;
@@ -44,6 +43,8 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public ?int $bmd_export_date;
public ?int $date_delivered;
public string $status;
+ public int $lock = 0;
+ public int $exported = 0;
public ?int $credit_for_invoice_id;
public int $create_by;
public int $edit_by;
diff --git a/application/ManualInvoiceposition/ManualInvoicepositionModel.php b/application/ManualInvoiceposition/ManualInvoicepositionModel.php
index ddaca0479..1a3d71227 100644
--- a/application/ManualInvoiceposition/ManualInvoicepositionModel.php
+++ b/application/ManualInvoiceposition/ManualInvoicepositionModel.php
@@ -4,11 +4,9 @@ class ManualInvoicepositionModel extends TTCrudBaseModel {
public int $id;
public ?int $manualinvoice_id;
public ?string $position_group;
- public ?int $billing_id;
- public int $contract_id;
public ?string $matchcode;
- public int $product_id;
- public string $product_name;
+ public int $warehousearticle_id;
+ public string $warehousearticle_name;
public ?string $product_info;
public float $amount;
public string $unit;
diff --git a/application/MobileApp/MobileAppController.php b/application/MobileApp/MobileAppController.php
new file mode 100644
index 000000000..3a356ceb3
--- /dev/null
+++ b/application/MobileApp/MobileAppController.php
@@ -0,0 +1,474 @@
+needlogin = false;
+ $me = mfValuecache::singleton()->get("me");
+ if (!$me) {
+ if (mfLoginController::isLoggedIn()) {
+ $me = new User();
+ $me->loadMe();
+ mfValuecache::singleton()->set("me", $me);
+ }
+ }
+ $this->user = $me;
+ }
+
+ /**
+ * Main dispatcher
+ */
+ public function indexAction() {
+ $module = $this->request->module ?? null;
+ $submodule = $this->request->submodule ?? null;
+ $endpoint = $this->request->endpoint ?? null;
+
+ // Auth endpoints: /MobileApp/auth/{action}
+ if (strtolower($module) === 'auth') {
+ return $this->handleAuth($submodule ?? 'check');
+ }
+
+ // API call: /MobileApp/{module}/{submodule}/{endpoint}
+ if ($module && $submodule && $endpoint) {
+ return $this->handleApiCall($module, $submodule, $endpoint);
+ }
+
+ // Everything else: render the main Vue SPA
+ // The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc.
+ return $this->renderApp();
+ }
+
+ /**
+ * Render the main Vue SPA
+ */
+ protected function renderApp() {
+ $this->layout()->setTemplate("MobileApp/App");
+ $this->layout()->set("JSGlobals", [
+ 'BASE_PATH' => '/MobileApp',
+ 'USER' => $this->user ? [
+ 'id' => $this->user->id,
+ 'name' => $this->user->name,
+ 'username' => $this->user->username,
+ ] : null,
+ 'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp',
+ ]);
+ }
+
+ /**
+ * Handle authentication endpoints
+ */
+ protected function handleAuth($action) {
+ switch (strtolower($action)) {
+ case 'login':
+ return $this->authLogin();
+ case 'verify2fa':
+ return $this->authVerify2FA();
+ case 'resend2fa':
+ return $this->authResend2FA();
+ case 'logout':
+ return $this->authLogout();
+ case 'check':
+ return $this->authCheck();
+ default:
+ self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404);
+ }
+ }
+
+ /**
+ * POST /MobileApp/auth/login
+ *
+ * Step 1 of authentication. If 2FA is required, returns requires2FA: true
+ * and the frontend should proceed to verify2fa endpoint.
+ */
+ protected function authLogin() {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
+ return;
+ }
+
+ $postData = json_decode(file_get_contents('php://input'), true) ?? [];
+ $username = $postData['username'] ?? '';
+ $password = $postData['password'] ?? '';
+ $rememberMe = $postData['rememberMe'] ?? false;
+
+ if (!$username || !$password) {
+ self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']);
+ return;
+ }
+
+ $db = FronkDB::singleton();
+ $escapedUsername = $db->escape($username);
+
+ $res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'");
+ if (!$db->num_rows($res)) {
+ sleep(1);
+ self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
+ return;
+ }
+
+ $userRow = $db->fetch_object($res);
+
+ if ($userRow->active == 0) {
+ self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']);
+ return;
+ }
+
+ $hash = $userRow->password;
+ $salt = substr($hash, 0, 16);
+ $passhash = mfLoginController::generatePasswordHash($password, $salt);
+
+ if ($passhash !== $hash) {
+ sleep(1);
+ self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
+ return;
+ }
+
+ // Check if 2FA is required
+ if ($userRow->twofactor !== "0") {
+ // Generate and send 2FA code
+ $twoFactor = new UserTwofactor($userRow->id);
+ $twoFactor->sendCode();
+
+ // Store pending auth in session for 2FA verification
+ $_SESSION['mobileapp_2fa_pending'] = [
+ 'user_id' => $userRow->id,
+ 'username' => $userRow->username,
+ 'remember_me' => $rememberMe,
+ 'timestamp' => time()
+ ];
+
+ // Determine delivery method for UI feedback
+ $deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms';
+ $maskedTarget = $deliveryMethod === 'email'
+ ? $this->maskEmail($userRow->email)
+ : $this->maskPhone($userRow->mobile);
+
+ self::returnJson([
+ 'success' => false,
+ 'requires2FA' => true,
+ 'deliveryMethod' => $deliveryMethod,
+ 'maskedTarget' => $maskedTarget,
+ 'message' => 'Verifizierungscode wurde gesendet'
+ ]);
+ return;
+ }
+
+ // No 2FA - complete login directly
+ $this->completeLogin($userRow, $rememberMe);
+ }
+
+ /**
+ * POST /MobileApp/auth/verify2fa
+ *
+ * Step 2 of authentication - verify the 2FA code
+ */
+ protected function authVerify2FA() {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
+ return;
+ }
+
+ $postData = json_decode(file_get_contents('php://input'), true) ?? [];
+ $code = $postData['code'] ?? '';
+
+ // Check for pending 2FA session
+ if (!isset($_SESSION['mobileapp_2fa_pending'])) {
+ self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
+ return;
+ }
+
+ $pending = $_SESSION['mobileapp_2fa_pending'];
+
+ // Check if pending session is expired (10 minutes max)
+ if (time() - $pending['timestamp'] > 600) {
+ unset($_SESSION['mobileapp_2fa_pending']);
+ self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
+ return;
+ }
+
+ if (!$code || strlen($code) !== 5) {
+ self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']);
+ return;
+ }
+
+ $db = FronkDB::singleton();
+ $userId = intval($pending['user_id']);
+
+ // Get user's 2FA code and timestamp
+ $res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}");
+ if (!$db->num_rows($res)) {
+ unset($_SESSION['mobileapp_2fa_pending']);
+ self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']);
+ return;
+ }
+
+ $userRow = $db->fetch_object($res);
+ $storedCode = $userRow->twofactorcode;
+ $codeTimestamp = intval($userRow->twofactortimestamp);
+
+ // Check if code is expired (5 minutes)
+ if (time() - $codeTimestamp > 300) {
+ self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]);
+ return;
+ }
+
+ // Verify code
+ if ($code !== $storedCode) {
+ sleep(1); // Rate limiting
+ self::returnJson(['success' => false, 'message' => 'Ungültiger Code']);
+ return;
+ }
+
+ // Clear the 2FA code
+ $twoFactor = new UserTwofactor($userId);
+ $twoFactor->removeCode();
+
+ // Clear pending session
+ unset($_SESSION['mobileapp_2fa_pending']);
+
+ // Get full user row for login completion
+ $res = $db->select(MFUSERTABLE, "*", "id = {$userId}");
+ $userRow = $db->fetch_object($res);
+
+ // Complete login
+ $this->completeLogin($userRow, $pending['remember_me']);
+ }
+
+ /**
+ * POST /MobileApp/auth/resend2fa
+ *
+ * Resend the 2FA code
+ */
+ protected function authResend2FA() {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
+ return;
+ }
+
+ // Check for pending 2FA session
+ if (!isset($_SESSION['mobileapp_2fa_pending'])) {
+ self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
+ return;
+ }
+
+ $pending = $_SESSION['mobileapp_2fa_pending'];
+
+ // Check if pending session is expired (10 minutes max)
+ if (time() - $pending['timestamp'] > 600) {
+ unset($_SESSION['mobileapp_2fa_pending']);
+ self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
+ return;
+ }
+
+ // Resend 2FA code
+ $twoFactor = new UserTwofactor($pending['user_id']);
+ $twoFactor->sendCode();
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => 'Neuer Code wurde gesendet'
+ ]);
+ }
+
+ /**
+ * Complete the login process after password (and optionally 2FA) verification
+ */
+ protected function completeLogin($userRow, $rememberMe) {
+ $db = FronkDB::singleton();
+
+ $db->update(MFUSERTABLE, [
+ 'ip' => $_SERVER['REMOTE_ADDR'],
+ 'sessionid' => session_id()
+ ], "id = {$userRow->id}");
+
+ $_SESSION[MFAPPNAME . '_username'] = $userRow->username;
+ $_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR'];
+
+ if ($rememberMe) {
+ UserToken::generateToken($userRow->id);
+ }
+
+ $user = new User();
+ $user->loadMe();
+
+ self::returnJson([
+ 'success' => true,
+ 'user' => [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'username' => $user->username,
+ ]
+ ]);
+ }
+
+ /**
+ * Mask email address for privacy (e.g., j***@example.com)
+ */
+ protected function maskEmail($email) {
+ if (!$email) return '***';
+ $parts = explode('@', $email);
+ if (count($parts) !== 2) return '***';
+ $local = $parts[0];
+ $domain = $parts[1];
+ $masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*';
+ return $masked . '@' . $domain;
+ }
+
+ /**
+ * Mask phone number for privacy (e.g., +43***123)
+ */
+ protected function maskPhone($phone) {
+ if (!$phone) return '***';
+ $phone = preg_replace('/\s+/', '', $phone);
+ if (strlen($phone) < 6) return '***';
+ return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3);
+ }
+
+ /**
+ * POST /MobileApp/auth/logout
+ */
+ protected function authLogout() {
+ mfLoginController::staticLogout();
+ self::returnJson(['success' => true]);
+ }
+
+ /**
+ * GET /MobileApp/auth/check
+ */
+ protected function authCheck() {
+ if (mfLoginController::isLoggedIn()) {
+ $user = new User();
+ $user->loadMe();
+
+ if ($user->id) {
+ self::returnJson([
+ 'authenticated' => true,
+ 'user' => [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'username' => $user->username,
+ ]
+ ]);
+ return;
+ }
+ }
+
+ UserToken::checkToken();
+
+ if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) {
+ $user = new User();
+ $user->loadMe();
+
+ if ($user->id) {
+ self::returnJson([
+ 'authenticated' => true,
+ 'user' => [
+ 'id' => $user->id,
+ 'name' => $user->name,
+ 'username' => $user->username,
+ ]
+ ]);
+ return;
+ }
+ }
+
+ self::returnJson(['authenticated' => false]);
+ }
+
+ /**
+ * Handle API calls to module endpoints
+ * /MobileApp/{module}/{submodule}/{endpoint}
+ */
+ protected function handleApiCall($module, $submodule, $endpoint) {
+ // Check authentication for API calls
+ if (!$this->user || !$this->user->id) {
+ self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
+ return;
+ }
+
+ // Find module directory (case-insensitive)
+ $moduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules", $module);
+ if (!$moduleName) {
+ self::returnJson(['success' => false, 'error' => "Module not found: {$module}"], 404);
+ return;
+ }
+
+ // Find submodule directory (case-insensitive)
+ $submoduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules/{$moduleName}", $submodule);
+ if (!$submoduleName) {
+ self::returnJson(['success' => false, 'error' => "Submodule not found: {$module}/{$submodule}"], 404);
+ return;
+ }
+
+ // Build handler path
+ $handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
+
+ if (!file_exists($handlerFile)) {
+ self::returnJson(['success' => false, 'error' => "Handler not found: {$moduleName}/{$submoduleName}"], 404);
+ return;
+ }
+
+ require_once $handlerFile;
+
+ $handlerClass = "{$submoduleName}Handler";
+
+ if (!class_exists($handlerClass)) {
+ self::returnJson(['success' => false, 'error' => "Handler class not found"], 500);
+ return;
+ }
+
+ $handler = new $handlerClass($this->request, $this->user, $this);
+
+ // Check permissions
+ if (!$handler->checkPermission()) {
+ self::returnJson(['success' => false, 'error' => 'Permission denied'], 403);
+ return;
+ }
+
+ // Route to method
+ $method = $endpoint . 'Action';
+ if (method_exists($handler, $method)) {
+ return $handler->$method();
+ }
+
+ if (method_exists($handler, $endpoint)) {
+ return $handler->$endpoint();
+ }
+
+ self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404);
+ }
+
+ /**
+ * Find directory with case-insensitive matching
+ * Required for Linux compatibility where filesystem is case-sensitive
+ */
+ protected function findModuleDirectory($basePath, $name) {
+ if (!is_dir($basePath)) return null;
+ $dirs = scandir($basePath);
+ foreach ($dirs as $dir) {
+ if ($dir === '.' || $dir === '..') continue;
+ if (strtolower($dir) === strtolower($name) && is_dir($basePath . '/' . $dir)) {
+ return $dir;
+ }
+ }
+ return null;
+ }
+}
diff --git a/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
new file mode 100644
index 000000000..ddd02ca85
--- /dev/null
+++ b/application/MobileApp/Modules/Lager/Inventur/InventurHandler.php
@@ -0,0 +1,409 @@
+ 'in_progress']);
+
+ $result = [];
+ foreach ($stocktakes as $stocktake) {
+ $location = $stocktake->getLocation();
+ $result[] = [
+ 'id' => $stocktake->id,
+ 'stocktakeNumber' => $stocktake->stocktakeNumber,
+ 'title' => $stocktake->title,
+ 'locationName' => $location ? $location->title : 'Unbekannt',
+ 'totalScannedItems' => $stocktake->totalScannedItems,
+ 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
+ ];
+ }
+
+ self::returnJson(['success' => true, 'stocktakes' => $result]);
+ }
+
+ public function getStocktakeAction() {
+ $id = intval($this->request->id);
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
+ return;
+ }
+
+ $stocktake = WarehouseStocktakeModel::get($id);
+ if (!$stocktake) {
+ self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
+ return;
+ }
+
+ $location = $stocktake->getLocation();
+
+ self::returnJson([
+ 'success' => true,
+ 'stocktake' => [
+ 'id' => $stocktake->id,
+ 'stocktakeNumber' => $stocktake->stocktakeNumber,
+ 'title' => $stocktake->title,
+ 'status' => $stocktake->status,
+ 'locationId' => $stocktake->warehouseLocationId,
+ 'locationName' => $location ? $location->title : 'Unbekannt',
+ 'totalScannedItems' => $stocktake->totalScannedItems,
+ 'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
+ ]
+ ]);
+ }
+
+ public function getArticleAction() {
+ $code = $this->request->code;
+
+ if (!$code) {
+ self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
+ return;
+ }
+
+ $articleId = null;
+
+ if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
+ $articleId = intval($matches[1]);
+ } else {
+ $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
+ if ($article) {
+ $articleId = $article->id;
+ }
+ }
+
+ if (!$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $category = WarehouseCategory::get($article->category_id);
+
+ self::returnJson([
+ 'success' => true,
+ 'article' => [
+ 'id' => $article->id,
+ 'articleNumber' => $article->articleNumber,
+ 'title' => $article->title,
+ 'description' => $article->description ?? '',
+ 'unit' => $article->unit ?? 'Stk.',
+ 'categoryName' => $category ? $category->name : '',
+ ]
+ ]);
+ }
+
+ public function searchArticlesAction() {
+ $query = $this->request->query ?? '';
+ $categoryId = intval($this->request->categoryId ?? 0);
+
+ $db = $this->db();
+ $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
+
+ if ($query && strlen($query) >= 2) {
+ $escapedQuery = $db->escape($query);
+ $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
+ }
+
+ if ($categoryId > 0) {
+ $conditions[] = "category_id = {$categoryId}";
+ }
+
+ if (count($conditions) === 1 && !$categoryId) {
+ self::returnJson(['success' => true, 'articles' => []]);
+ return;
+ }
+
+ $whereClause = implode(' AND ', $conditions);
+ $result = $db->query("SELECT id, articleNumber, title, unit, category_id
+ FROM WarehouseArticle
+ WHERE {$whereClause}
+ ORDER BY title ASC
+ LIMIT 50");
+
+ $articles = [];
+ while ($row = $result->fetch_assoc()) {
+ $articles[] = [
+ 'id' => intval($row['id']),
+ 'articleNumber' => $row['articleNumber'],
+ 'title' => $row['title'],
+ 'unit' => $row['unit'] ?? 'Stk.',
+ 'categoryId' => intval($row['category_id'] ?? 0),
+ ];
+ }
+
+ self::returnJson(['success' => true, 'articles' => $articles]);
+ }
+
+ public function getCategoriesAction() {
+ $db = $this->db();
+ $res = $db->query("SELECT id, name FROM WarehouseCategory ORDER BY name ASC");
+
+ $categories = [];
+ while ($row = $res->fetch_assoc()) {
+ $categories[] = [
+ 'id' => intval($row['id']),
+ 'name' => $row['name'],
+ ];
+ }
+ self::returnJson(['success' => true, 'categories' => $categories]);
+ }
+
+ public function checkAlreadyScannedAction() {
+ $stocktakeId = intval($this->request->stocktakeId);
+ $articleId = intval($this->request->articleId);
+
+ if (!$stocktakeId || !$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
+ return;
+ }
+
+ $existing = WarehouseStocktakeItemModel::getFirst([
+ 'stocktakeId' => $stocktakeId,
+ 'articleId' => $articleId,
+ 'overwrittenById' => null
+ ]);
+
+ if ($existing) {
+ $db = $this->db();
+ $scannedByResult = $db->query("SELECT name FROM Worker WHERE id = {$existing->scannedBy}");
+ $scannedByRow = $scannedByResult->fetch_assoc();
+
+ self::returnJson([
+ 'success' => true,
+ 'alreadyScanned' => true,
+ 'existingItem' => [
+ 'id' => $existing->id,
+ 'countedQuantity' => $existing->countedQuantity,
+ 'scannedAt' => $existing->scannedAt ? date('d.m.Y H:i', $existing->scannedAt) : null,
+ 'scannedBy' => $scannedByRow ? $scannedByRow['name'] : 'Unbekannt',
+ ]
+ ]);
+ } else {
+ self::returnJson(['success' => true, 'alreadyScanned' => false]);
+ }
+ }
+
+ public function submitScanAction() {
+ $postData = $this->getPostData();
+
+ $stocktakeId = intval($postData['stocktakeId'] ?? 0);
+ $articleId = intval($postData['articleId'] ?? 0);
+ $quantity = floatval($postData['quantity'] ?? 0);
+ $rack = $postData['rack'] ?? null;
+ $shelf = $postData['shelf'] ?? null;
+ $note = $postData['note'] ?? null;
+ $overwrite = boolval($postData['overwrite'] ?? false);
+ $overwriteItemId = intval($postData['overwriteItemId'] ?? 0);
+
+ if (!$stocktakeId || !$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
+ return;
+ }
+
+ if ($quantity <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
+ return;
+ }
+
+ $stocktake = WarehouseStocktakeModel::get($stocktakeId);
+ if (!$stocktake) {
+ self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
+ return;
+ }
+
+ if ($stocktake->status !== 'in_progress') {
+ self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
+ return;
+ }
+
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $db = $this->db();
+
+ if ($overwrite && $overwriteItemId) {
+ $db->query("INSERT INTO WarehouseStocktakeItem
+ (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
+ VALUES ({$stocktakeId}, {$articleId}, {$quantity},
+ " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
+ " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
+ " . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
+ " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
+
+ $itemId = $db->insert_id;
+ $db->query("UPDATE WarehouseStocktakeItem SET overwrittenById = {$itemId} WHERE id = {$overwriteItemId}");
+ $finalQuantity = $quantity;
+
+ WarehouseStocktakeLogModel::log($stocktakeId, 'overwritten', $itemId, [
+ 'articleId' => $articleId,
+ 'articleNumber' => $article->articleNumber,
+ 'articleTitle' => $article->title,
+ 'quantity' => $quantity,
+ 'overwrittenItemId' => $overwriteItemId,
+ ]);
+
+ $stocktake->updateProgress();
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => "'{$article->title}' überschrieben ({$quantity} {$article->unit})",
+ 'item' => [
+ 'id' => $itemId,
+ 'articleId' => $articleId,
+ 'articleNumber' => $article->articleNumber,
+ 'articleTitle' => $article->title,
+ 'countedQuantity' => $finalQuantity,
+ 'unit' => $article->unit ?? 'Stk.',
+ 'rack' => $rack,
+ 'shelf' => $shelf,
+ 'isOverwrite' => true,
+ ]
+ ]);
+ return;
+ }
+
+ $existing = WarehouseStocktakeItemModel::getFirst([
+ 'stocktakeId' => $stocktakeId,
+ 'articleId' => $articleId,
+ 'overwrittenById' => null
+ ]);
+
+ if ($existing) {
+ $newQuantity = $existing->countedQuantity + $quantity;
+ $db->query("UPDATE WarehouseStocktakeItem SET
+ countedQuantity = {$newQuantity},
+ rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
+ shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
+ scannedAt = " . time() . ",
+ scannedBy = {$this->user->id}
+ WHERE id = {$existing->id}");
+
+ $itemId = $existing->id;
+ $finalQuantity = $newQuantity;
+ $isUpdate = true;
+ } else {
+ $db->query("INSERT INTO WarehouseStocktakeItem
+ (stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
+ VALUES ({$stocktakeId}, {$articleId}, {$quantity},
+ " . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
+ " . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
+ " . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
+ " . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
+
+ $itemId = $db->insert_id;
+ $finalQuantity = $quantity;
+ $isUpdate = false;
+ }
+
+ $stocktake->updateProgress();
+
+ WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
+ 'articleId' => $articleId,
+ 'articleNumber' => $article->articleNumber,
+ 'articleTitle' => $article->title,
+ 'quantity' => $quantity,
+ 'totalQuantity' => $finalQuantity,
+ 'rack' => $rack,
+ 'shelf' => $shelf,
+ 'isUpdate' => $isUpdate,
+ ]);
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => $isUpdate
+ ? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
+ : "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
+ 'item' => [
+ 'id' => $itemId,
+ 'articleId' => $articleId,
+ 'articleNumber' => $article->articleNumber,
+ 'articleTitle' => $article->title,
+ 'countedQuantity' => $finalQuantity,
+ 'unit' => $article->unit ?? 'Stk.',
+ 'rack' => $rack,
+ 'shelf' => $shelf,
+ 'isUpdate' => $isUpdate,
+ ]
+ ]);
+ }
+
+ public function getMyScansAction() {
+ $stocktakeId = intval($this->request->stocktakeId);
+
+ if (!$stocktakeId) {
+ self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
+ return;
+ }
+
+ $db = $this->db();
+ $result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
+ FROM WarehouseStocktakeItem si
+ JOIN WarehouseArticle wa ON wa.id = si.articleId
+ WHERE si.stocktakeId = {$stocktakeId}
+ AND si.scannedBy = {$this->user->id}
+ ORDER BY si.scannedAt DESC
+ LIMIT 50");
+
+ $items = [];
+ while ($row = $result->fetch_assoc()) {
+ $items[] = [
+ 'id' => intval($row['id']),
+ 'articleId' => intval($row['articleId']),
+ 'articleNumber' => $row['articleNumber'],
+ 'articleTitle' => $row['articleTitle'],
+ 'countedQuantity' => floatval($row['countedQuantity']),
+ 'unit' => $row['unit'] ?? 'Stk.',
+ 'rack' => $row['rack'],
+ 'shelf' => $row['shelf'],
+ 'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
+ ];
+ }
+
+ self::returnJson(['success' => true, 'items' => $items]);
+ }
+
+ public function getProgressAction() {
+ $stocktakeId = intval($this->request->stocktakeId);
+
+ if (!$stocktakeId) {
+ self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
+ return;
+ }
+
+ $stocktake = WarehouseStocktakeModel::get($stocktakeId);
+ if (!$stocktake) {
+ self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
+ return;
+ }
+
+ $db = $this->db();
+
+ $totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
+ $totalRow = $totalResult->fetch_assoc();
+ $totalScanned = intval($totalRow['count']);
+
+ $myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
+ $myRow = $myResult->fetch_assoc();
+ $myScanned = intval($myRow['count']);
+
+ self::returnJson([
+ 'success' => true,
+ 'progress' => [
+ 'totalScanned' => $totalScanned,
+ 'myScanned' => $myScanned,
+ 'status' => $stocktake->status,
+ ]
+ ]);
+ }
+}
diff --git a/application/MobileApp/Modules/Lager/Movement/MovementHandler.php b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php
new file mode 100644
index 000000000..d3e499019
--- /dev/null
+++ b/application/MobileApp/Modules/Lager/Movement/MovementHandler.php
@@ -0,0 +1,581 @@
+title);
+ if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
+ $locations[] = [
+ 'id' => $location->id,
+ 'title' => $location->title,
+ ];
+ }
+ }
+
+ self::returnJson(['success' => true, 'locations' => $locations]);
+ }
+
+ public function getArticleAction() {
+ $code = $this->request->code;
+
+ if (!$code) {
+ self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
+ return;
+ }
+
+ $articleId = null;
+
+ // Check for QR code format WA:ID: or WH:ID:
+ if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
+ $articleId = intval($matches[1]);
+ } else {
+ // Try to find by article number
+ $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
+ if ($article) {
+ $articleId = $article->id;
+ }
+ }
+
+ if (!$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $category = WarehouseCategory::get($article->category_id);
+
+ self::returnJson([
+ 'success' => true,
+ 'article' => [
+ 'id' => $article->id,
+ 'articleNumber' => $article->articleNumber,
+ 'title' => $article->title,
+ 'description' => $article->description ?? '',
+ 'unit' => $article->unit ?? 'Stk.',
+ 'categoryName' => $category ? $category->name : '',
+ ]
+ ]);
+ }
+
+ public function searchArticlesAction() {
+ $query = $this->request->query ?? '';
+
+ $db = $this->db();
+ $conditions = ["(isEndOfLife IS NULL OR isEndOfLife = 0)"];
+
+ if ($query && strlen($query) >= 2) {
+ $escapedQuery = $db->escape($query);
+ $conditions[] = "(articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%' OR description LIKE '%{$escapedQuery}%')";
+ } else {
+ self::returnJson(['success' => true, 'articles' => []]);
+ return;
+ }
+
+ $whereClause = implode(' AND ', $conditions);
+ $result = $db->query("SELECT id, articleNumber, title, unit, category_id
+ FROM WarehouseArticle
+ WHERE {$whereClause}
+ ORDER BY title ASC
+ LIMIT 50");
+
+ $articles = [];
+ while ($row = $result->fetch_assoc()) {
+ $articles[] = [
+ 'id' => intval($row['id']),
+ 'articleNumber' => $row['articleNumber'],
+ 'title' => $row['title'],
+ 'unit' => $row['unit'] ?? 'Stk.',
+ ];
+ }
+
+ self::returnJson(['success' => true, 'articles' => $articles]);
+ }
+
+ public function getReasonCategoriesAction() {
+ $type = $this->request->type ?? null;
+
+ $categories = WarehouseMovementModel::getReasonCategories($type);
+
+ if ($type && is_array($categories)) {
+ $items = [];
+ foreach ($categories as $key => $label) {
+ $items[] = ['value' => $key, 'text' => $label];
+ }
+ self::returnJson(['success' => true, 'categories' => $items]);
+ } else {
+ self::returnJson(['success' => true, 'categories' => $categories]);
+ }
+ }
+
+ public function getCurrentStockAction() {
+ $articleId = intval($this->request->articleId ?? 0);
+ $locationId = intval($this->request->locationId ?? 0);
+
+ if (!$articleId || !$locationId) {
+ self::returnJson(['success' => true, 'currentStock' => 0]);
+ return;
+ }
+
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+
+ $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
+
+ self::returnJson(['success' => true, 'currentStock' => $currentStock]);
+ }
+
+ public function submitMovementAction() {
+ $postData = $this->getPostData();
+
+ $movementType = $postData['movementType'] ?? '';
+ $articleId = intval($postData['articleId'] ?? 0);
+ $locationId = intval($postData['locationId'] ?? 0);
+ $quantity = floatval($postData['quantity'] ?? 0);
+ $reasonCategory = $postData['reasonCategory'] ?? '';
+ $note = $postData['note'] ?? null;
+
+ // Validate required fields
+ if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
+ self::returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
+ return;
+ }
+
+ if ($articleId <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
+ return;
+ }
+
+ if ($locationId <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
+ return;
+ }
+
+ if ($quantity <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
+ return;
+ }
+
+ if (empty($reasonCategory)) {
+ self::returnJson(['success' => false, 'message' => 'Bitte Grund auswählen']);
+ return;
+ }
+
+ // Get article info
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $db = $this->db();
+
+ // Find or create WarehouseItem for this article at this location
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+
+ $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
+ $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
+
+ // Calculate new quantity based on movement type
+ // Note: Negative stock is allowed (items can be taken out even if stock is 0)
+ switch ($movementType) {
+ case 'IN':
+ $newQty = $currentQty + $quantity;
+ break;
+ case 'OUT':
+ $newQty = $currentQty - $quantity;
+ // Negative stock is allowed - no validation needed
+ break;
+ case 'ADJUSTMENT':
+ // For adjustment, quantity is the new absolute value
+ $newQty = $quantity;
+ break;
+ default:
+ $newQty = $currentQty;
+ }
+
+ // Update or create WarehouseItem
+ $warehouseItemId = null;
+ if ($warehouseItem) {
+ $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
+ $warehouseItemId = $warehouseItem->id;
+ } else {
+ $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
+ VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
+ $warehouseItemId = $db->insert_id;
+ }
+
+ // Create the movement record
+ $noteEscaped = $note ? "'" . $db->escape($note) . "'" : "NULL";
+ $db->query("INSERT INTO WarehouseMovement
+ (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, note, userId, createBy, `create`)
+ VALUES ('{$movementType}', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, '{$db->escape($reasonCategory)}', {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
+
+ $movementId = $db->insert_id;
+
+ // Generate movement number
+ $movementNumber = WarehouseMovementModel::generateMovementNumber();
+ $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
+
+ // Get type label for message
+ $typeLabels = ['IN' => 'Einbuchung', 'OUT' => 'Ausbuchung', 'ADJUSTMENT' => 'Korrektur'];
+ $typeLabel = $typeLabels[$movementType] ?? $movementType;
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => "{$typeLabel} erfolgreich: {$quantity} x {$article->title}",
+ 'movement' => [
+ 'id' => $movementId,
+ 'movementNumber' => $movementNumber,
+ 'movementType' => $movementType,
+ 'articleId' => $articleId,
+ 'articleTitle' => $article->title,
+ 'quantity' => $quantity,
+ 'quantityBefore' => $currentQty,
+ 'quantityAfter' => $newQty,
+ ]
+ ]);
+ }
+
+ public function getMyMovementsAction() {
+ $locationId = intval($this->request->locationId ?? 0);
+ $limit = intval($this->request->limit ?? 20);
+
+ $db = $this->db();
+
+ $whereClause = "m.userId = {$this->user->id}";
+ if ($locationId > 0) {
+ $whereClause .= " AND m.warehouseLocationId = {$locationId}";
+ }
+
+ $result = $db->query("SELECT m.*, wa.articleNumber, wa.title as articleTitle, wa.unit, wl.title as locationTitle
+ FROM WarehouseMovement m
+ LEFT JOIN WarehouseArticle wa ON wa.id = m.articleId
+ LEFT JOIN WarehouseLocation wl ON wl.id = m.warehouseLocationId
+ WHERE {$whereClause}
+ ORDER BY m.`create` DESC
+ LIMIT {$limit}");
+
+ $movements = [];
+ while ($row = $result->fetch_assoc()) {
+ $movements[] = [
+ 'id' => intval($row['id']),
+ 'movementNumber' => $row['movementNumber'],
+ 'movementType' => $row['movementType'],
+ 'articleId' => intval($row['articleId']),
+ 'articleNumber' => $row['articleNumber'],
+ 'articleTitle' => $row['articleTitle'],
+ 'unit' => $row['unit'] ?? 'Stk.',
+ 'locationTitle' => $row['locationTitle'],
+ 'quantity' => floatval($row['quantity']),
+ 'quantityBefore' => floatval($row['quantityBefore']),
+ 'quantityAfter' => floatval($row['quantityAfter']),
+ 'reasonCategory' => $row['reasonCategory'],
+ 'note' => $row['note'],
+ 'create' => date('d.m.Y H:i', $row['create']),
+ ];
+ }
+
+ self::returnJson(['success' => true, 'movements' => $movements]);
+ }
+
+ public function getMovementTypesAction() {
+ $types = [
+ ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'plus-circle', 'color' => 'green'],
+ ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'minus-circle', 'color' => 'red'],
+ ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'edit', 'color' => 'yellow'],
+ ];
+
+ self::returnJson(['success' => true, 'types' => $types]);
+ }
+
+ public function getPendingOrdersAction() {
+ $db = $this->db();
+
+ $result = $db->query("SELECT wo.*, wd.name as distributorName
+ FROM WarehouseOrder wo
+ LEFT JOIN WarehouseDistributor wd ON wd.id = wo.distributorId
+ WHERE wo.status IN ('sent', 'partiallyDelivered')
+ ORDER BY wo.`create` DESC");
+
+ $orders = [];
+ while ($row = $result->fetch_assoc()) {
+ $positions = json_decode($row['positions'], true) ?: [];
+ $totalItems = array_sum(array_column($positions, 'amount'));
+
+ // Calculate days since sent
+ $daysSinceSent = 0;
+ if (!empty($row['create'])) {
+ $daysSinceSent = floor((time() - intval($row['create'])) / 86400);
+ }
+
+ $orders[] = [
+ 'id' => intval($row['id']),
+ 'orderNumber' => $row['orderNumber'],
+ 'distributorName' => $row['distributorName'] ?? 'Unbekannt',
+ 'status' => $row['status'],
+ 'statusLabel' => $row['status'] === 'sent' ? 'Versendet' : 'Teilweise geliefert',
+ 'totalItems' => $totalItems,
+ 'positionCount' => count($positions),
+ 'daysSinceSent' => $daysSinceSent,
+ 'create' => date('d.m.Y', $row['create']),
+ ];
+ }
+
+ self::returnJson(['success' => true, 'orders' => $orders]);
+ }
+
+ public function getOrderForReceivingAction() {
+ $orderId = intval($this->request->orderId ?? 0);
+
+ if ($orderId <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
+ return;
+ }
+
+ $order = WarehouseOrderModel::get($orderId);
+ if (!$order) {
+ self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
+ return;
+ }
+
+ if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
+ self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
+ return;
+ }
+
+ $distributor = WarehouseDistributorModel::get($order->distributorId);
+ $positions = json_decode($order->positions, true) ?: [];
+
+ // Get already delivered quantities from linked movements
+ $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
+ $deliveredByArticle = [];
+
+ foreach ($linkedMovementIds as $movementId) {
+ $movement = WarehouseMovementModel::get($movementId);
+ if ($movement && $movement->movementType === 'IN') {
+ if (!isset($deliveredByArticle[$movement->articleId])) {
+ $deliveredByArticle[$movement->articleId] = 0;
+ }
+ $deliveredByArticle[$movement->articleId] += $movement->quantity;
+ }
+ }
+
+ // Enrich positions with article details and delivered quantities
+ $enrichedPositions = [];
+ foreach ($positions as $index => $pos) {
+ $articleId = intval($pos['article']);
+ $article = WarehouseArticleModel::get($articleId);
+
+ $orderedQty = floatval($pos['amount']);
+ $deliveredQty = $deliveredByArticle[$articleId] ?? 0;
+ $remainingQty = max(0, $orderedQty - $deliveredQty);
+
+ $enrichedPositions[] = [
+ 'index' => $index,
+ 'articleId' => $articleId,
+ 'articleNumber' => $article ? $article->articleNumber : '',
+ 'articleTitle' => $article ? $article->title : ($pos['article_text'] ?? 'Unbekannt'),
+ 'unit' => $article ? ($article->unit ?? 'Stk.') : 'Stk.',
+ 'orderedQty' => $orderedQty,
+ 'deliveredQty' => $deliveredQty,
+ 'remainingQty' => $remainingQty,
+ 'receivingQty' => $remainingQty, // Default to remaining
+ ];
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'order' => [
+ 'id' => $order->id,
+ 'orderNumber' => $order->orderNumber,
+ 'distributorName' => $distributor ? $distributor->name : 'Unbekannt',
+ 'status' => $order->status,
+ 'note' => $order->note,
+ 'create' => date('d.m.Y H:i', $order->create),
+ ],
+ 'positions' => $enrichedPositions
+ ]);
+ }
+
+ public function submitOrderReceivingAction() {
+ $postData = $this->getPostData();
+
+ $orderId = intval($postData['orderId'] ?? 0);
+ $locationId = intval($postData['locationId'] ?? 0);
+ $positions = $postData['positions'] ?? [];
+ $deliveryNoteFileId = $postData['deliveryNoteFileId'] ?? null;
+ $note = $postData['note'] ?? null;
+
+ // Validation
+ if ($orderId <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Keine Bestellung angegeben']);
+ return;
+ }
+
+ if ($locationId <= 0) {
+ self::returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
+ return;
+ }
+
+ if (empty($positions)) {
+ self::returnJson(['success' => false, 'message' => 'Keine Positionen angegeben']);
+ return;
+ }
+
+ $order = WarehouseOrderModel::get($orderId);
+ if (!$order) {
+ self::returnJson(['success' => false, 'message' => 'Bestellung nicht gefunden']);
+ return;
+ }
+
+ if (!in_array($order->status, ['sent', 'partiallyDelivered'])) {
+ self::returnJson(['success' => false, 'message' => 'Bestellung ist nicht im Status Versendet oder Teilweise geliefert']);
+ return;
+ }
+
+ $db = $this->db();
+ $createdMovementIds = [];
+ $totalReceived = 0;
+
+ // Create movements for each position with quantity > 0
+ foreach ($positions as $pos) {
+ $articleId = intval($pos['articleId'] ?? 0);
+ $quantity = floatval($pos['quantity'] ?? 0);
+
+ if ($articleId <= 0 || $quantity <= 0) {
+ continue;
+ }
+
+ // Find or create WarehouseItem
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+
+ $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
+ $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
+ $newQty = $currentQty + $quantity;
+
+ // Update or create WarehouseItem
+ if ($warehouseItem) {
+ $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
+ $warehouseItemId = $warehouseItem->id;
+ } else {
+ $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
+ VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
+ $warehouseItemId = $db->insert_id;
+ }
+
+ // Create movement record
+ $movementNote = "Lagereingang aus Bestellung {$order->orderNumber}";
+ if ($note) {
+ $movementNote .= " - " . $note;
+ }
+ $noteEscaped = "'" . $db->escape($movementNote) . "'";
+
+ $db->query("INSERT INTO WarehouseMovement
+ (movementType, articleId, warehouseLocationId, warehouseItemId, quantity, quantityBefore, quantityAfter, reasonCategory, linkedOrderId, note, userId, createBy, `create`)
+ VALUES ('IN', {$articleId}, {$locationId}, {$warehouseItemId}, {$quantity}, {$currentQty}, {$newQty}, 'Warenlieferung', {$orderId}, {$noteEscaped}, {$this->user->id}, {$this->user->id}, " . time() . ")");
+
+ $movementId = $db->insert_id;
+
+ // Generate movement number
+ $movementNumber = WarehouseMovementModel::generateMovementNumber();
+ $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movementId}");
+
+ $createdMovementIds[] = $movementId;
+ $totalReceived += $quantity;
+ }
+
+ if (empty($createdMovementIds)) {
+ self::returnJson(['success' => false, 'message' => 'Keine Mengen eingegeben']);
+ return;
+ }
+
+ // Update order with linked movement IDs
+ $existingMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
+ $allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
+
+ // Update delivery note file IDs if provided
+ $existingFileIds = $order->deliveryNoteFileIds ? json_decode($order->deliveryNoteFileIds, true) : [];
+ if ($deliveryNoteFileId) {
+ $existingFileIds[] = $deliveryNoteFileId;
+ }
+
+ // Determine new status - check if all items are now fully delivered
+ $orderPositions = json_decode($order->positions, true) ?: [];
+ $allFullyDelivered = true;
+
+ // Get all delivered quantities including new ones
+ $deliveredByArticle = [];
+ foreach ($allMovementIds as $movementId) {
+ $movement = WarehouseMovementModel::get($movementId);
+ if ($movement && $movement->movementType === 'IN') {
+ if (!isset($deliveredByArticle[$movement->articleId])) {
+ $deliveredByArticle[$movement->articleId] = 0;
+ }
+ $deliveredByArticle[$movement->articleId] += $movement->quantity;
+ }
+ }
+
+ foreach ($orderPositions as $pos) {
+ $articleId = intval($pos['article']);
+ $orderedQty = floatval($pos['amount']);
+ $deliveredQty = $deliveredByArticle[$articleId] ?? 0;
+
+ if ($deliveredQty < $orderedQty) {
+ $allFullyDelivered = false;
+ break;
+ }
+ }
+
+ $newStatus = $allFullyDelivered ? 'fullyDelivered' : 'partiallyDelivered';
+
+ // Update order
+ $orderAsArray = (array)$order;
+ $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
+ $orderAsArray['deliveryNoteFileIds'] = json_encode($existingFileIds);
+ $orderAsArray['status'] = $newStatus;
+ WarehouseOrderModel::update($orderAsArray);
+
+ // Create log entry
+ $logMessage = count($createdMovementIds) . " Lagerbewegung(en) erstellt via Mobile App.";
+ if ($note) {
+ $logMessage .= "\n" . $note;
+ }
+
+ WarehouseLogModel::create([
+ 'table' => 'WarehouseOrder',
+ 'rowId' => $orderId,
+ 'type' => 'statusChange',
+ 'message' => "Status geändert auf " . ($newStatus === 'fullyDelivered' ? 'Geliefert' : 'Teilweise geliefert') . ".\n" . $logMessage,
+ 'createBy' => $this->user->id,
+ 'create' => time()
+ ]);
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => "{$totalReceived} Artikel empfangen. " . count($createdMovementIds) . " Lagerbewegung(en) erstellt.",
+ 'newStatus' => $newStatus,
+ 'createdMovementIds' => $createdMovementIds
+ ]);
+ }
+}
diff --git a/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php
new file mode 100644
index 000000000..4464462e9
--- /dev/null
+++ b/application/MobileApp/Modules/Lager/ShippingNote/ShippingNoteHandler.php
@@ -0,0 +1,821 @@
+ ShippingNote module.
+ * API Base: /MobileApp/Lager/ShippingNote/{action}
+ */
+class ShippingNoteHandler extends MobileAppBaseHandler {
+
+ protected $requiredPermission = 'WarehouseUser';
+
+ // Office coordinates for distance calculation
+ const OFFICE_LAT = 46.99552810791587;
+ const OFFICE_LNG = 15.7751923956463;
+
+ public function initializeAction() {
+ $db = $this->db();
+ $userId = $this->user->id;
+
+ $userCar = null;
+ $sql = "SELECT id, number_plate, brand, model
+ FROM TimerecordingCar
+ WHERE user_id = {$userId}
+ AND (retired IS NULL OR retired = 0)
+ LIMIT 1";
+ $result = $db->query($sql);
+ if ($result && $row = $result->fetch_assoc()) {
+ $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
+ if (!$carName) $carName = $row['number_plate'];
+ $userCar = [
+ 'id' => intval($row['id']),
+ 'name' => $carName,
+ 'plate' => $row['number_plate'],
+ ];
+ }
+
+ $allCars = [];
+ $sql = "SELECT id, number_plate, brand, model
+ FROM TimerecordingCar
+ WHERE (retired IS NULL OR retired = 0)
+ ORDER BY brand, model ASC";
+ $result = $db->query($sql);
+ while ($row = $result->fetch_assoc()) {
+ $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
+ if (!$carName) $carName = $row['number_plate'];
+ $allCars[] = [
+ 'id' => intval($row['id']),
+ 'name' => $carName,
+ 'plate' => $row['number_plate'],
+ ];
+ }
+
+ $hourTypes = [
+ ['id' => '', 'name' => 'Normal'],
+ ['id' => '50', 'name' => '+50%'],
+ ['id' => '100', 'name' => '+100%'],
+ ['id' => 'regie', 'name' => 'Regie'],
+ ];
+
+ $currentUser = [
+ 'id' => $this->user->id,
+ 'name' => $this->user->name,
+ 'firstname' => $this->user->firstname ?? '',
+ 'lastname' => $this->user->lastname ?? '',
+ ];
+
+ self::returnJson([
+ 'success' => true,
+ 'userCar' => $userCar,
+ 'allCars' => $allCars,
+ 'hourTypes' => $hourTypes,
+ 'currentUser' => $currentUser,
+ ]);
+ }
+
+ /**
+ * Get customer by GPS location (nearest within radius)
+ * GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y
+ */
+ public function getCustomerByLocationAction() {
+ $lat = floatval($this->request->lat ?? 0);
+ $lng = floatval($this->request->lng ?? 0);
+ $radius = intval($this->request->radius ?? 200); // default 200 meters
+
+ if (!$lat || !$lng) {
+ self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
+ return;
+ }
+
+ $db = $this->db();
+
+ // Haversine formula for distance in meters
+ $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long,
+ (6371000 * acos(
+ cos(radians({$lat})) * cos(radians(gps_lat)) *
+ cos(radians(gps_long) - radians({$lng})) +
+ sin(radians({$lat})) * sin(radians(gps_lat))
+ )) AS distance
+ FROM Address
+ WHERE gps_lat IS NOT NULL
+ AND gps_long IS NOT NULL
+ AND customer_number > 0
+ HAVING distance < {$radius}
+ ORDER BY distance ASC
+ LIMIT 1";
+
+ $result = $db->query($sql);
+
+ if ($result && $row = $result->fetch_assoc()) {
+ // Build display name
+ $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
+
+ self::returnJson([
+ 'success' => true,
+ 'found' => true,
+ 'customer' => [
+ 'id' => intval($row['id']),
+ 'customerNumber' => $row['customer_number'],
+ 'displayName' => $displayName,
+ 'company' => $row['company'],
+ 'firstname' => $row['firstname'],
+ 'lastname' => $row['lastname'],
+ 'street' => $row['street'],
+ 'zip' => $row['zip'],
+ 'city' => $row['city'],
+ 'email' => $row['email'],
+ 'phone' => $row['phone'],
+ 'distance' => round(floatval($row['distance'])),
+ ]
+ ]);
+ } else {
+ self::returnJson([
+ 'success' => true,
+ 'found' => false,
+ 'message' => 'Kein Kunde in der Nähe gefunden'
+ ]);
+ }
+ }
+
+ /**
+ * Reverse geocode coordinates to address
+ * GET /MobileApp/Lager/ShippingNote/reverseGeocode?lat=X&lng=Y
+ */
+ public function reverseGeocodeAction() {
+ $lat = floatval($this->request->lat ?? 0);
+ $lng = floatval($this->request->lng ?? 0);
+
+ if (!$lat || !$lng) {
+ self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
+ return;
+ }
+
+ // Use Google Maps Geocoding API
+ $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
+ if (!$apiKey) {
+ self::returnJson(['success' => false, 'message' => 'Google Maps API nicht konfiguriert']);
+ return;
+ }
+
+ $url = "https://maps.googleapis.com/maps/api/geocode/json?latlng={$lat},{$lng}&key={$apiKey}&language=de";
+
+ $response = @file_get_contents($url);
+ if (!$response) {
+ self::returnJson(['success' => false, 'message' => 'Geocoding fehlgeschlagen']);
+ return;
+ }
+
+ $data = json_decode($response, true);
+ if ($data['status'] !== 'OK' || empty($data['results'])) {
+ self::returnJson(['success' => false, 'message' => 'Keine Adresse gefunden']);
+ return;
+ }
+
+ // Parse address components
+ $result = $data['results'][0];
+ $components = $result['address_components'];
+
+ $street = '';
+ $streetNumber = '';
+ $zip = '';
+ $city = '';
+
+ foreach ($components as $comp) {
+ if (in_array('route', $comp['types'])) {
+ $street = $comp['long_name'];
+ }
+ if (in_array('street_number', $comp['types'])) {
+ $streetNumber = $comp['long_name'];
+ }
+ if (in_array('postal_code', $comp['types'])) {
+ $zip = $comp['long_name'];
+ }
+ if (in_array('locality', $comp['types'])) {
+ $city = $comp['long_name'];
+ }
+ }
+
+ $fullStreet = trim($street . ' ' . $streetNumber);
+
+ self::returnJson([
+ 'success' => true,
+ 'address' => [
+ 'street' => $fullStreet,
+ 'zip' => $zip,
+ 'city' => $city,
+ 'formatted' => $result['formatted_address'] ?? '',
+ ]
+ ]);
+ }
+
+ /**
+ * Search customers by name/company
+ * GET /MobileApp/Lager/ShippingNote/searchCustomers?query=X
+ */
+ public function searchCustomersAction() {
+ $query = trim($this->request->query ?? '');
+
+ if (strlen($query) < 1) {
+ self::returnJson(['success' => true, 'customers' => []]);
+ return;
+ }
+
+ $db = $this->db();
+
+ // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
+ $words = preg_split('/\s+/', trim($query));
+ $wordConditions = [];
+
+ foreach ($words as $word) {
+ if (strlen($word) < 1) continue;
+ $escapedWord = $db->escape($word);
+ $wordConditions[] = "(company LIKE '%{$escapedWord}%'
+ OR firstname LIKE '%{$escapedWord}%'
+ OR lastname LIKE '%{$escapedWord}%'
+ OR customer_number LIKE '%{$escapedWord}%')";
+ }
+
+ if (empty($wordConditions)) {
+ self::returnJson(['success' => true, 'customers' => []]);
+ return;
+ }
+
+ $whereClause = implode(' AND ', $wordConditions);
+
+ $sql = "SELECT id, customer_number, company, firstname, lastname, street, zip, city, email, phone, gps_lat, gps_long
+ FROM Address
+ WHERE customer_number > 0
+ AND ({$whereClause})
+ ORDER BY company, lastname, firstname
+ LIMIT 20";
+
+ $result = $db->query($sql);
+ $customers = [];
+
+ while ($row = $result->fetch_assoc()) {
+ $displayName = $row['company'] ?: trim($row['firstname'] . ' ' . $row['lastname']);
+ $customers[] = [
+ 'id' => intval($row['id']),
+ 'customerNumber' => $row['customer_number'],
+ 'displayName' => $displayName,
+ 'company' => $row['company'],
+ 'firstname' => $row['firstname'],
+ 'lastname' => $row['lastname'],
+ 'street' => $row['street'],
+ 'zip' => $row['zip'],
+ 'city' => $row['city'],
+ 'email' => $row['email'],
+ 'phone' => $row['phone'],
+ 'gpsLat' => $row['gps_lat'] ? floatval($row['gps_lat']) : null,
+ 'gpsLong' => $row['gps_long'] ? floatval($row['gps_long']) : null,
+ ];
+ }
+
+ self::returnJson(['success' => true, 'customers' => $customers]);
+ }
+
+ /**
+ * Search articles
+ * GET /MobileApp/Lager/ShippingNote/searchArticles?query=X
+ */
+ public function searchArticlesAction() {
+ $query = trim($this->request->query ?? '');
+
+ if (strlen($query) < 1) {
+ self::returnJson(['success' => true, 'articles' => []]);
+ return;
+ }
+
+ $db = $this->db();
+ $escapedQuery = $db->escape($query);
+
+ $sql = "SELECT id, articleNumber, title, unit
+ FROM WarehouseArticle
+ WHERE (isEndOfLife IS NULL OR isEndOfLife = 0)
+ AND (articleNumber LIKE '%{$escapedQuery}%'
+ OR title LIKE '%{$escapedQuery}%')
+ ORDER BY title ASC
+ LIMIT 30";
+
+ $result = $db->query($sql);
+ $articles = [];
+
+ while ($row = $result->fetch_assoc()) {
+ $articles[] = [
+ 'id' => intval($row['id']),
+ 'articleNumber' => $row['articleNumber'],
+ 'title' => $row['title'],
+ 'unit' => $row['unit'] ?? 'Stk.',
+ ];
+ }
+
+ self::returnJson(['success' => true, 'articles' => $articles]);
+ }
+
+ /**
+ * Get article by QR code or article number
+ * GET /MobileApp/Lager/ShippingNote/getArticle?code=X
+ */
+ public function getArticleAction() {
+ $code = $this->request->code ?? '';
+
+ if (!$code) {
+ self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
+ return;
+ }
+
+ $articleId = null;
+
+ // Check for QR code format WA:ID: or WH:ID:
+ if (preg_match('/^(?:WA|WH):(\d+):/', $code, $matches)) {
+ $articleId = intval($matches[1]);
+ } else {
+ // Try to find by article number
+ $article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
+ if ($article) {
+ $articleId = $article->id;
+ }
+ }
+
+ if (!$articleId) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ $article = WarehouseArticleModel::get($articleId);
+ if (!$article) {
+ self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'article' => [
+ 'id' => $article->id,
+ 'articleNumber' => $article->articleNumber,
+ 'title' => $article->title,
+ 'unit' => $article->unit ?? 'Stk.',
+ ]
+ ]);
+ }
+
+ /**
+ * Get user's assigned car
+ * GET /MobileApp/Lager/ShippingNote/getUserCar?userId=X
+ */
+ public function getUserCarAction() {
+ $userId = intval($this->request->userId ?? $this->user->id);
+
+ $db = $this->db();
+
+ // Get user's assigned car from TimerecordingCar (user_id is on TimerecordingCar)
+ $sql = "SELECT id, number_plate, brand, model
+ FROM TimerecordingCar
+ WHERE user_id = {$userId}
+ AND (retired IS NULL OR retired = 0)
+ LIMIT 1";
+
+ $result = $db->query($sql);
+
+ if ($result && $row = $result->fetch_assoc()) {
+ $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
+ if (!$carName) $carName = $row['number_plate'];
+
+ self::returnJson([
+ 'success' => true,
+ 'car' => [
+ 'id' => intval($row['id']),
+ 'name' => $carName,
+ 'plate' => $row['number_plate'],
+ ]
+ ]);
+ } else {
+ self::returnJson([
+ 'success' => true,
+ 'car' => null
+ ]);
+ }
+ }
+
+ /**
+ * Get all available cars for selection
+ * GET /MobileApp/Lager/ShippingNote/getAllCars
+ */
+ public function getAllCarsAction() {
+ $db = $this->db();
+
+ $sql = "SELECT id, number_plate, brand, model
+ FROM TimerecordingCar
+ WHERE (retired IS NULL OR retired = 0)
+ ORDER BY brand, model ASC";
+
+ $result = $db->query($sql);
+ $cars = [];
+
+ while ($row = $result->fetch_assoc()) {
+ $carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
+ if (!$carName) $carName = $row['number_plate'];
+
+ $cars[] = [
+ 'id' => intval($row['id']),
+ 'name' => $carName,
+ 'plate' => $row['number_plate'],
+ ];
+ }
+
+ self::returnJson(['success' => true, 'cars' => $cars]);
+ }
+
+ /**
+ * Get hour types for selection
+ * GET /MobileApp/Lager/ShippingNote/getHourTypes
+ */
+ public function getHourTypesAction() {
+ // Hour types matching desktop modal
+ $hourTypes = [
+ ['id' => '', 'name' => 'Normal'],
+ ['id' => '50', 'name' => '+50%'],
+ ['id' => '100', 'name' => '+100%'],
+ ['id' => 'regie', 'name' => 'Regie'],
+ ];
+
+ self::returnJson(['success' => true, 'hourTypes' => $hourTypes]);
+ }
+
+ /**
+ * Calculate round-trip distance from office to coordinates
+ * GET /MobileApp/Lager/ShippingNote/calculateDistance?lat=X&lng=Y
+ */
+ public function calculateDistanceAction() {
+ $lat = floatval($this->request->lat ?? 0);
+ $lng = floatval($this->request->lng ?? 0);
+
+ if (!$lat || !$lng) {
+ self::returnJson(['success' => false, 'message' => 'Koordinaten fehlen']);
+ return;
+ }
+
+ // Use estimation on localhost for development
+ $isLocalhost = in_array($_SERVER['HTTP_HOST'] ?? '', ['localhost', '127.0.0.1']);
+
+ // Use Google Distance Matrix API for accurate driving distance
+ $apiKey = defined('TT_GEOCODING_API_SECRET') ? TT_GEOCODING_API_SECRET : '';
+ if (!$apiKey || $isLocalhost) {
+ // Fallback to straight-line distance * 1.3 (rough road factor)
+ $distance = $this->haversineDistance(self::OFFICE_LAT, self::OFFICE_LNG, $lat, $lng);
+ $kmOneWay = round($distance / 1000 * 1.3, 1);
+ $kmRoundTrip = $kmOneWay * 2;
+
+ self::returnJson([
+ 'success' => true,
+ 'distanceOneWay' => $kmOneWay,
+ 'distanceRoundTrip' => $kmRoundTrip,
+ 'estimated' => true
+ ]);
+ return;
+ }
+
+ $origin = self::OFFICE_LAT . ',' . self::OFFICE_LNG;
+ $destination = "{$lat},{$lng}";
+ $url = "https://maps.googleapis.com/maps/api/distancematrix/json?origins={$origin}&destinations={$destination}&mode=driving&key={$apiKey}";
+
+ $response = @file_get_contents($url);
+ if (!$response) {
+ self::returnJson(['success' => false, 'message' => 'Distanzberechnung fehlgeschlagen']);
+ return;
+ }
+
+ $data = json_decode($response, true);
+ if ($data['status'] !== 'OK' || empty($data['rows'][0]['elements'][0]['distance'])) {
+ self::returnJson(['success' => false, 'message' => 'Keine Route gefunden']);
+ return;
+ }
+
+ $distanceMeters = $data['rows'][0]['elements'][0]['distance']['value'];
+ $kmOneWay = round($distanceMeters / 1000, 1);
+ $kmRoundTrip = $kmOneWay * 2;
+
+ self::returnJson([
+ 'success' => true,
+ 'distanceOneWay' => $kmOneWay,
+ 'distanceRoundTrip' => $kmRoundTrip,
+ 'estimated' => false
+ ]);
+ }
+
+ /**
+ * Create new shipping note
+ * POST /MobileApp/Lager/ShippingNote/create
+ */
+ public function createAction() {
+ $postData = $this->getPostData();
+
+ // Validate required fields
+ $requiredFields = ['deliveryAddressName', 'deliveryAddressLine', 'deliveryAddressPLZ', 'deliveryAddressCity', 'note'];
+ foreach ($requiredFields as $field) {
+ if (empty($postData[$field])) {
+ self::returnJson(['success' => false, 'message' => "Feld '{$field}' ist erforderlich"]);
+ return;
+ }
+ }
+
+ // Must have at least positions OR hoursEntries
+ $positions = $postData['positions'] ?? [];
+ $hoursEntries = $postData['hoursEntries'] ?? [];
+
+ if (empty($positions) && empty($hoursEntries)) {
+ self::returnJson(['success' => false, 'message' => 'Mindestens eine Position oder Stundenbuchung erforderlich']);
+ return;
+ }
+
+ $db = $this->db();
+
+ // Prepare data
+ $data = [
+ 'status' => 'new',
+ 'type' => null,
+ 'billingAddressId' => null,
+ 'deliveryAddressName' => $db->escape($postData['deliveryAddressName']),
+ 'deliveryAddressLine' => $db->escape($postData['deliveryAddressLine']),
+ 'deliveryAddressPLZ' => $db->escape($postData['deliveryAddressPLZ']),
+ 'deliveryAddressCity' => $db->escape($postData['deliveryAddressCity']),
+ 'deliveryAddressEMail' => $db->escape($postData['deliveryAddressEMail'] ?? ''),
+ 'note' => $db->escape($postData['note']),
+ 'positions' => json_encode($positions),
+ 'hoursEntries' => json_encode($hoursEntries),
+ 'textElements' => json_encode($postData['textElements'] ?? []),
+ 'metadata' => json_encode($postData['metadata'] ?? []),
+ 'create' => time(),
+ 'createBy' => $this->user->id,
+ ];
+
+ // Generate shipping note number
+ $shippingNoteNumber = WarehouseShippingNoteModel::generateShippingNoteNumber();
+ $data['shippingNoteNumber'] = $shippingNoteNumber;
+
+ // Build INSERT query
+ $columns = implode(', ', array_map(function($k) { return "`{$k}`"; }, array_keys($data)));
+ $values = implode(', ', array_map(function($v) {
+ return $v === null ? 'NULL' : "'{$v}'";
+ }, array_values($data)));
+
+ $db->query("INSERT INTO WarehouseShippingNote ({$columns}) VALUES ({$values})");
+ $id = $db->insert_id;
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => 'Lieferschein erstellt',
+ 'shippingNote' => [
+ 'id' => $id,
+ 'shippingNoteNumber' => $shippingNoteNumber,
+ ]
+ ]);
+ }
+
+ /**
+ * Sign a shipping note
+ * POST /MobileApp/Lager/ShippingNote/sign?id=X
+ */
+ public function signAction() {
+ $id = intval($this->request->id ?? 0);
+ $postData = $this->getPostData();
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'Lieferschein-ID fehlt']);
+ return;
+ }
+
+ $shippingNote = WarehouseShippingNoteModel::get($id);
+ if (!$shippingNote) {
+ self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
+ return;
+ }
+
+ // Check if already signed
+ if (!empty($shippingNote->signature) || !empty($shippingNote->signatureName)) {
+ self::returnJson(['success' => false, 'message' => 'Bereits unterschrieben']);
+ return;
+ }
+
+ // Validate signature data
+ $signature = $postData['signature'] ?? '';
+ $signatureName = $postData['signatureName'] ?? '';
+
+ if (empty($signature) || empty($signatureName)) {
+ self::returnJson(['success' => false, 'message' => 'Unterschrift und Name erforderlich']);
+ return;
+ }
+
+ $db = $this->db();
+ $signatureEscaped = $db->escape($signature);
+ $signatureNameEscaped = $db->escape($signatureName);
+ $signatureDate = date('Y-m-d');
+
+ $db->query("UPDATE WarehouseShippingNote
+ SET signature = '{$signatureEscaped}',
+ signatureName = '{$signatureNameEscaped}',
+ signatureDate = '{$signatureDate}'
+ WHERE id = {$id}");
+
+ self::returnJson([
+ 'success' => true,
+ 'message' => 'Unterschrift gespeichert'
+ ]);
+ }
+
+ /**
+ * Get my unsigned shipping notes
+ * GET /MobileApp/Lager/ShippingNote/getMyShippingNotes
+ */
+ public function getMyShippingNotesAction() {
+ $onlyUnsigned = ($this->request->unsigned ?? '1') === '1';
+ $limit = intval($this->request->limit ?? 20);
+
+ $db = $this->db();
+
+ $whereClause = "createBy = {$this->user->id}";
+ if ($onlyUnsigned) {
+ $whereClause .= " AND (signature IS NULL OR signature = '')";
+ }
+
+ $sql = "SELECT id, shippingNoteNumber, status, type, deliveryAddressName, deliveryAddressLine,
+ deliveryAddressPLZ, deliveryAddressCity, note,
+ signature, signatureName, signatureDate, `create`
+ FROM WarehouseShippingNote
+ WHERE {$whereClause}
+ ORDER BY `create` DESC
+ LIMIT {$limit}";
+
+ $result = $db->query($sql);
+ $shippingNotes = [];
+
+ while ($row = $result->fetch_assoc()) {
+ $shippingNotes[] = [
+ 'id' => intval($row['id']),
+ 'shippingNoteNumber' => $row['shippingNoteNumber'],
+ 'status' => $row['status'],
+ 'type' => $row['type'],
+ 'deliveryAddressName' => $row['deliveryAddressName'],
+ 'deliveryAddressLine' => $row['deliveryAddressLine'],
+ 'deliveryAddressPLZ' => $row['deliveryAddressPLZ'],
+ 'deliveryAddressCity' => $row['deliveryAddressCity'],
+ 'note' => $row['note'],
+ 'isSigned' => !empty($row['signature']),
+ 'signatureName' => $row['signatureName'],
+ 'signatureDate' => $row['signatureDate'],
+ 'create' => date('d.m.Y H:i', $row['create']),
+ ];
+ }
+
+ self::returnJson(['success' => true, 'shippingNotes' => $shippingNotes]);
+ }
+
+ /**
+ * Get a single shipping note by ID
+ * GET /MobileApp/Lager/ShippingNote/getShippingNote?id=X
+ */
+ public function getShippingNoteAction() {
+ $id = intval($this->request->id ?? 0);
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'ID fehlt']);
+ return;
+ }
+
+ $shippingNote = WarehouseShippingNoteModel::get($id);
+ if (!$shippingNote) {
+ self::returnJson(['success' => false, 'message' => 'Lieferschein nicht gefunden']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'shippingNote' => [
+ 'id' => intval($shippingNote->id),
+ 'shippingNoteNumber' => $shippingNote->shippingNoteNumber,
+ 'status' => $shippingNote->status,
+ 'type' => $shippingNote->type,
+ 'billingAddressId' => $shippingNote->billingAddressId,
+ 'deliveryAddressName' => $shippingNote->deliveryAddressName,
+ 'deliveryAddressLine' => $shippingNote->deliveryAddressLine,
+ 'deliveryAddressPLZ' => $shippingNote->deliveryAddressPLZ,
+ 'deliveryAddressCity' => $shippingNote->deliveryAddressCity,
+ 'deliveryAddressEMail' => $shippingNote->deliveryAddressEMail,
+ 'note' => $shippingNote->note,
+ 'positions' => json_decode($shippingNote->positions, true) ?? [],
+ 'hoursEntries' => json_decode($shippingNote->hoursEntries, true) ?? [],
+ 'isSigned' => !empty($shippingNote->signature),
+ 'signatureName' => $shippingNote->signatureName,
+ 'signatureDate' => $shippingNote->signatureDate,
+ 'create' => date('d.m.Y H:i', $shippingNote->create),
+ ]
+ ]);
+ }
+
+ /**
+ * Get shipping note types
+ * GET /MobileApp/Lager/ShippingNote/getTypes
+ */
+ public function getTypesAction() {
+ $types = [
+ ['value' => 'V', 'text' => 'Verrechnen'],
+ ['value' => 'XI', 'text' => 'Xinon Intern'],
+ ['value' => 'XH', 'text' => 'Xinon Hersteller'],
+ ['value' => 'SNOPP', 'text' => 'SNOPP'],
+ ['value' => 'ESTMK', 'text' => 'Energie Steiermark'],
+ ['value' => 'SBIDI', 'text' => 'SBIDI'],
+ ];
+
+ self::returnJson(['success' => true, 'types' => $types]);
+ }
+
+ /**
+ * Get current user info (for pre-filling forms)
+ * GET /MobileApp/Lager/ShippingNote/getCurrentUser
+ */
+ public function getCurrentUserAction() {
+ self::returnJson([
+ 'success' => true,
+ 'user' => [
+ 'id' => $this->user->id,
+ 'name' => $this->user->name,
+ 'firstname' => $this->user->firstname ?? '',
+ 'lastname' => $this->user->lastname ?? '',
+ ]
+ ]);
+ }
+
+ /**
+ * Search employees for multi-employee selection
+ * GET /MobileApp/Lager/ShippingNote/searchEmployees?query=X
+ */
+ public function searchEmployeesAction() {
+ $query = trim($this->request->query ?? '');
+
+ $db = $this->db();
+
+ // Base query: active workers who have TimerecordingEmployee entry (= employees)
+ $sql = "SELECT w.id, w.name, w.email
+ FROM Worker w
+ INNER JOIN TimerecordingEmployee te ON te.user_id = w.id
+ WHERE w.active = 1";
+
+ // Add search filter if query provided
+ if (strlen($query) >= 1) {
+ // Split query into words for lazy search (e.g., "fab her" matches "Fabian Herbst")
+ $words = preg_split('/\s+/', trim($query));
+ $wordConditions = [];
+
+ foreach ($words as $word) {
+ if (strlen($word) < 1) continue;
+ $escapedWord = $db->escape($word);
+ $wordConditions[] = "(w.name LIKE '%{$escapedWord}%' OR w.email LIKE '%{$escapedWord}%')";
+ }
+
+ if (!empty($wordConditions)) {
+ $sql .= " AND " . implode(' AND ', $wordConditions);
+ }
+ }
+
+ $sql .= " ORDER BY w.name ASC LIMIT 20";
+
+ $result = $db->query($sql);
+ $employees = [];
+
+ while ($row = $result->fetch_assoc()) {
+ $employees[] = [
+ 'id' => intval($row['id']),
+ 'name' => $row['name'],
+ 'email' => $row['email'],
+ ];
+ }
+
+ self::returnJson(['success' => true, 'employees' => $employees]);
+ }
+
+ /**
+ * Helper: Calculate Haversine distance in meters
+ */
+ private function haversineDistance($lat1, $lng1, $lat2, $lng2) {
+ $earthRadius = 6371000; // meters
+
+ $dLat = deg2rad($lat2 - $lat1);
+ $dLng = deg2rad($lng2 - $lng1);
+
+ $a = sin($dLat / 2) * sin($dLat / 2) +
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
+ sin($dLng / 2) * sin($dLng / 2);
+
+ $c = 2 * atan2(sqrt($a), sqrt(1 - $a));
+
+ return $earthRadius * $c;
+ }
+}
diff --git a/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php
new file mode 100644
index 000000000..6861bcdc7
--- /dev/null
+++ b/application/MobileApp/Modules/Workorder/Workorder/WorkorderHandler.php
@@ -0,0 +1,1750 @@
+ ['text' => 'Neu', 'color' => 'primary'],
+ 'assigned' => ['text' => 'Zugewiesen', 'color' => 'info'],
+ 'scheduled' => ['text' => 'Geplant', 'color' => 'warning'],
+ 'in_progress' => ['text' => 'In Bearbeitung', 'color' => 'warning'],
+ 'correction_requested' => ['text' => 'Korrektur angefordert', 'color' => 'danger'],
+ 'intervention_required' => ['text' => 'Eingriff erforderlich', 'color' => 'danger'],
+ 'civil_engineering_required' => ['text' => 'Tiefbau benötigt', 'color' => 'orange'],
+ 'civil_engineering_completed' => ['text' => 'Tiefbau abgeschlossen', 'color' => 'success'],
+ 'problem_solved' => ['text' => 'Problem gelöst', 'color' => 'success'],
+ 'documented' => ['text' => 'Dokumentiert', 'color' => 'success'],
+ 'completed' => ['text' => 'Abgeschlossen', 'color' => 'secondary'],
+ 'charged' => ['text' => 'Verrechnet', 'color' => 'purple'],
+ 'cancelled' => ['text' => 'Abgebrochen', 'color' => 'danger'],
+ 'archived' => ['text' => 'Archiviert', 'color' => 'muted'],
+ ];
+
+ /**
+ * Get workorders list for the company
+ * POST /MobileApp/Workorder/Workorder/get
+ */
+ public function getAction() {
+ $postData = $this->getPostData();
+
+ $pagination = $postData['pagination'] ?? ['page' => 1, 'per_page' => 20];
+ $filters = $postData['filters'] ?? [];
+ $order = $postData['order'] ?? [];
+ $search = trim($postData['search'] ?? '');
+
+ // Get company for current user
+ $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
+ if (!$company) {
+ self::returnJson([
+ 'success' => true,
+ 'workorders' => [],
+ 'pagination' => ['page' => 1, 'per_page' => $pagination['per_page'], 'total' => 0]
+ ]);
+ return;
+ }
+
+ // Build workorders query
+ $workorders = WorkorderModel::getCompanyWorkorders(
+ $filters,
+ $pagination['per_page'],
+ ($pagination['page'] - 1) * $pagination['per_page'],
+ $order,
+ $company->id,
+ $search
+ );
+
+ $totalCount = WorkorderModel::countCompanyWorkorders($filters, $company->id, $search);
+
+ // Transform for mobile app
+ $result = [];
+ foreach ($workorders as $wo) {
+ $result[] = $this->transformWorkorder($wo);
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'workorders' => $result,
+ 'pagination' => [
+ 'page' => intval($pagination['page']),
+ 'per_page' => intval($pagination['per_page']),
+ 'total' => $totalCount,
+ 'totalPages' => ceil($totalCount / $pagination['per_page'])
+ ]
+ ]);
+ }
+
+ /**
+ * Get single workorder details
+ * GET /MobileApp/Workorder/Workorder/getWorkorder?id=X
+ */
+ public function getWorkorderAction() {
+ $id = intval($this->request->id ?? 0);
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'ID fehlt']);
+ return;
+ }
+
+ // Verify user has access
+ $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
+ if (!$company) {
+ self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']);
+ return;
+ }
+
+ // Get workorder with full joined data
+ $workorder = $this->getWorkorderWithDetails($id, $company->id);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'workorder' => $this->transformWorkorder($workorder, true)
+ ]);
+ }
+
+ /**
+ * Get complete workorder detail (combined endpoint)
+ * Returns workorder, documentation, tenant config, and checklist in one request
+ * GET /MobileApp/Workorder/Workorder/getWorkorderDetail?id=X
+ */
+ public function getWorkorderDetailAction() {
+ $id = intval($this->request->id ?? 0);
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'ID fehlt']);
+ return;
+ }
+
+ // Verify user has access
+ $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
+ if (!$company) {
+ self::returnJson(['success' => false, 'message' => 'Keine Berechtigung']);
+ return;
+ }
+
+ // Get workorder with full joined data
+ $workorderData = $this->getWorkorderWithDetails($id, $company->id);
+ if (!$workorderData) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ $workorder = $this->transformWorkorder($workorderData, true);
+
+ // Get tenant config
+ $tenantConfig = $this->getTenantConfigFromWorkorder($id);
+ $tenantConfigData = null;
+ $translationMap = [];
+
+ if ($tenantConfig) {
+ $customTypes = json_decode($tenantConfig->documentationTypes, true) ?? [];
+ $translationMap = array_merge(
+ ['civil_engineering_photo' => 'Tiefbau_Foto'],
+ array_column($customTypes, 'text', 'value')
+ );
+
+ $tenantConfigData = [
+ 'addressId' => $tenantConfig->addressId,
+ 'documentationTypes' => $customTypes,
+ 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired,
+ 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
+ 'requireCableLength' => (bool)$tenantConfig->requireCableLength,
+ 'requireCableType' => (bool)$tenantConfig->requireCableType,
+ 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
+ ];
+ }
+
+ // Get documentation
+ $docs = WorkorderDocumentationModel::getAll(
+ ['workorderId' => $id],
+ null, 0,
+ ['key' => 'create', 'order' => 'ASC']
+ );
+
+ $typeCounts = [];
+ $responseDocs = [];
+
+ foreach ($docs as $doc) {
+ $file = new File($doc->fileId);
+ $documentTypeKey = $doc->documentType;
+ $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
+ $originalFilename = $file->orig_filename ?? $file->filename;
+ $extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
+ $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
+ $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
+
+ $responseDocs[] = [
+ 'id' => $doc->id,
+ 'fileId' => $doc->fileId,
+ 'fileName' => $newFilename,
+ 'description' => $doc->description,
+ 'documentType' => $documentTypeKey,
+ 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey,
+ 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
+ 'mimetype' => $file->mimetype ?? 'application/octet-stream',
+ 'create' => $doc->create,
+ 'createFormatted' => date('d.m.Y H:i', $doc->create),
+ 'previewUrl' => "/File/Download/{$doc->fileId}",
+ ];
+ }
+
+ // Get journals
+ $journals = WorkorderJournalModel::getAll(
+ ['workorderId' => $id],
+ null, 0,
+ ['key' => 'create', 'order' => 'DESC']
+ );
+
+ $responseJournals = [];
+ foreach ($journals as $journal) {
+ $responseJournals[] = [
+ 'id' => $journal->id,
+ 'text' => $journal->text,
+ 'statusChange' => $journal->statusChange ?? null,
+ 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
+ 'create' => $journal->create,
+ 'createFormatted' => date('d.m.Y H:i', $journal->create),
+ ];
+ }
+
+ // Build checklist
+ $docTypes = $tenantConfigData['documentationTypes'] ?? [];
+ $uploadedTypes = array_column((array)$docs, 'documentType');
+ $uploadedTypeCounts = array_count_values($uploadedTypes);
+
+ $checklist = [];
+ $completedCount = 0;
+
+ foreach ($docTypes as $type) {
+ $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0;
+ if ($isCompleted) $completedCount++;
+
+ $checklist[] = [
+ 'type' => $type['value'],
+ 'text' => $type['text'],
+ 'required' => $type['required'] ?? false,
+ 'completed' => $isCompleted,
+ 'count' => $uploadedTypeCounts[$type['value']] ?? 0,
+ ];
+ }
+
+ $technicalData = null;
+ if ($tenantConfigData && !empty($tenantConfigData['showTechnicalData'])) {
+ RimoWorkorder::autoParseForWorkorder($id);
+ $technicalData = WorkorderModel::getTechnicalData($id);
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'workorder' => $workorder,
+ 'tenantConfig' => $tenantConfigData,
+ 'docs' => $responseDocs,
+ 'journals' => $responseJournals,
+ 'checklist' => $checklist,
+ 'checklistProgress' => [
+ 'completed' => $completedCount,
+ 'total' => count($docTypes),
+ 'allRequired' => $this->allRequiredCompleted($checklist)
+ ],
+ 'technicalData' => $technicalData,
+ ]);
+ }
+
+ /**
+ * Get documentation and journals for a workorder
+ * GET /MobileApp/Workorder/Workorder/getDocumentation?workorderId=X
+ */
+ public function getDocumentationAction() {
+ $workorderId = intval($this->request->workorderId ?? 0);
+
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ // Get documentation
+ $docs = WorkorderDocumentationModel::getAll(
+ ['workorderId' => $workorderId],
+ null, 0,
+ ['key' => 'create', 'order' => 'ASC']
+ );
+
+ // Get journals
+ $journals = WorkorderJournalModel::getAll(
+ ['workorderId' => $workorderId],
+ null, 0,
+ ['key' => 'create', 'order' => 'DESC']
+ );
+
+ // Get tenant config for type translations
+ $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
+ $translationMap = [];
+ if ($tenantConfig && !empty($tenantConfig->documentationTypes)) {
+ $customTypes = json_decode($tenantConfig->documentationTypes, true);
+ $customMap = array_column($customTypes, 'text', 'value');
+ $translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
+ }
+
+ // Transform docs
+ $responseDocs = [];
+ $typeCounts = [];
+
+ foreach ($docs as $doc) {
+ $file = new File($doc->fileId);
+ $documentTypeKey = $doc->documentType;
+ $typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
+ $originalFilename = $file->orig_filename ?? $file->filename;
+ $extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
+ $translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
+ $newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
+
+ $responseDocs[] = [
+ 'id' => $doc->id,
+ 'fileId' => $doc->fileId,
+ 'fileName' => $newFilename,
+ 'description' => $doc->description,
+ 'documentType' => $documentTypeKey,
+ 'documentTypeText' => $translationMap[$documentTypeKey] ?? $documentTypeKey,
+ 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
+ 'mimetype' => $file->mimetype ?? 'application/octet-stream',
+ 'create' => $doc->create,
+ 'createFormatted' => date('d.m.Y H:i', $doc->create),
+ 'previewUrl' => "/File/Download/{$doc->fileId}",
+ ];
+ }
+
+ // Transform journals
+ $responseJournals = [];
+ foreach ($journals as $journal) {
+ $responseJournals[] = [
+ 'id' => $journal->id,
+ 'text' => $journal->text,
+ 'statusChange' => $journal->statusChange ?? null,
+ 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
+ 'create' => $journal->create,
+ 'createFormatted' => date('d.m.Y H:i', $journal->create),
+ ];
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'docs' => $responseDocs,
+ 'journals' => $responseJournals,
+ 'docCount' => count($responseDocs),
+ 'journalCount' => count($responseJournals)
+ ]);
+ }
+
+ /**
+ * Get tenant configuration
+ * GET /MobileApp/Workorder/Workorder/getTenantConfig?workorderId=X
+ */
+ public function getTenantConfigAction() {
+ $workorderId = intval($this->request->workorderId ?? 0);
+
+ $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
+
+ if (!$tenantConfig) {
+ self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [],
+ 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired,
+ 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
+ 'requireCableLength' => (bool)$tenantConfig->requireCableLength,
+ 'requireCableType' => (bool)$tenantConfig->requireCableType,
+ ]);
+ }
+
+ /**
+ * Upload documentation files
+ * POST /MobileApp/Workorder/Workorder/uploadDocumentation
+ */
+ public function uploadDocumentationAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ if (empty($_FILES['files']) && empty($_FILES['file'])) {
+ self::returnJson(['success' => false, 'message' => 'Keine Datei hochgeladen']);
+ return;
+ }
+
+ $workorderId = intval($_POST['workorderId'] ?? 0);
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ $documentType = $_POST['documentType'] ?? 'general';
+ $description = $_POST['description'] ?? '';
+ $clientTimestamp = intval($_POST['clientTimestamp'] ?? 0);
+
+ // Handle both single file and multiple files
+ if (!empty($_FILES['files'])) {
+ foreach ($_FILES['files']['name'] as $index => $name) {
+ if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) {
+ $_FILES['file'] = [
+ 'name' => $name,
+ 'type' => $_FILES['files']['type'][$index],
+ 'tmp_name' => $_FILES['files']['tmp_name'][$index],
+ 'error' => $_FILES['files']['error'][$index],
+ 'size' => $_FILES['files']['size'][$index]
+ ];
+ $this->saveDocumentation($workorderId, $documentType, $description);
+ }
+ }
+ } else if (!empty($_FILES['file'])) {
+ $this->saveDocumentation($workorderId, $documentType, $description);
+ }
+
+ // Update workorder status if needed
+ $workorder = WorkorderModel::get($workorderId);
+ $oldStatus = $workorder->status;
+ $newStatus = null;
+
+ if (in_array($oldStatus, ['assigned', 'scheduled'])) {
+ $newStatus = 'in_progress';
+ } else if (in_array($oldStatus, ['correction_requested', 'problem_solved', 'civil_engineering_completed'])) {
+ $newStatus = 'assigned';
+ }
+
+ if ($newStatus) {
+ $workorder->status = $newStatus;
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Status wurde nach Dokumenten-Upload automatisch geändert.',
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText($newStatus),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+ }
+
+ $response = ['success' => true, 'message' => 'Datei(en) erfolgreich hochgeladen'];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Delete documentation
+ * POST /MobileApp/Workorder/Workorder/deleteDocumentation
+ */
+ public function deleteDocumentationAction() {
+ $postData = $this->getPostData();
+ $id = intval($postData['id'] ?? 0);
+
+ if (!$id) {
+ self::returnJson(['success' => false, 'message' => 'Dokumenten-ID fehlt']);
+ return;
+ }
+
+ WorkorderDocumentationModel::delete($id);
+ self::returnJson(['success' => true, 'message' => 'Dokument gelöscht']);
+ }
+
+ /**
+ * Add journal entry
+ * POST /MobileApp/Workorder/Workorder/addJournal
+ */
+ public function addJournalAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $text = trim($postData['text'] ?? '');
+
+ if (!$workorderId || !$text) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID und Text sind erforderlich']);
+ return;
+ }
+
+ // Use client timestamp if provided
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorderId,
+ 'text' => $text,
+ 'createBy' => $this->user->id,
+ 'create' => $clientTimestamp ?: time()
+ ]);
+
+ // Return updated journals
+ $journals = WorkorderJournalModel::getAll(
+ ['workorderId' => $workorderId],
+ null, 0,
+ ['key' => 'create', 'order' => 'DESC']
+ );
+
+ $responseJournals = [];
+ foreach ($journals as $journal) {
+ $responseJournals[] = [
+ 'id' => $journal->id,
+ 'text' => $journal->text,
+ 'statusChange' => $journal->statusChange ?? null,
+ 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
+ 'create' => $journal->create,
+ 'createFormatted' => date('d.m.Y H:i', $journal->create),
+ ];
+ }
+
+ $response = [
+ 'success' => true,
+ 'message' => 'Journaleintrag hinzugefügt',
+ 'journals' => $responseJournals
+ ];
+
+ // Cache for idempotency
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Update additional info (notes)
+ * POST /MobileApp/Workorder/Workorder/updateAdditionalInfo
+ */
+ public function updateAdditionalInfoAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ $oldInfo = $workorder->additionalInfo;
+ $newInfo = $postData['additionalInfo'] ?? null;
+ $workorder->additionalInfo = $newInfo;
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ $response = [
+ 'success' => true,
+ 'message' => 'Zusatzinfo aktualisiert',
+ 'newInfo' => $newInfo
+ ];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Schedule appointment
+ * POST /MobileApp/Workorder/Workorder/scheduleAppointment
+ */
+ public function scheduleAppointmentAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $appointmentDate = intval($postData['appointmentDate'] ?? 0);
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ if (!$workorderId || !$appointmentDate) {
+ self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']);
+ return;
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ // Validate time is set
+ $hour = (int)date('H', $appointmentDate);
+ if ($hour >= 23 || $hour < 1) {
+ self::returnJson(['success' => false, 'message' => 'Bitte geben Sie eine Uhrzeit an']);
+ return;
+ }
+
+ $workorder->appointmentDate = $appointmentDate;
+ $workorder->status = 'scheduled';
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ $response = ['success' => true, 'message' => 'Termin erfolgreich gespeichert'];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Request intervention (report problem)
+ * POST /MobileApp/Workorder/Workorder/requestIntervention
+ */
+ public function requestInterventionAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $journalText = trim($postData['journalText'] ?? '');
+ $interventionType = $postData['interventionType'] ?? '';
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ if (!$workorderId || !$journalText) {
+ self::returnJson(['success' => false, 'message' => 'Erforderliche Felder fehlen']);
+ return;
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ $oldStatus = $workorder->status;
+ $workorder->status = 'intervention_required';
+ WorkorderModel::update((array)$workorder);
+
+ $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}";
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => $fullText,
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ $response = ['success' => true, 'message' => 'Eingriff wurde angefordert'];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Complete workorder
+ * POST /MobileApp/Workorder/Workorder/completeWorkorder
+ */
+ public function completeWorkorderAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ // Validate cable data if required
+ $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
+ if ($tenantConfig) {
+ if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) {
+ self::returnJson(['success' => false, 'message' => 'Bitte geben Sie die Kabellänge an']);
+ return;
+ }
+ if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) {
+ self::returnJson(['success' => false, 'message' => 'Bitte geben Sie den Kabeltyp an']);
+ return;
+ }
+ }
+
+ // Validate checklist
+ $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : [];
+ $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]);
+ $uploadedTypes = array_column((array)$docs, 'documentType');
+ $uploadedTypeCounts = array_count_values($uploadedTypes);
+
+ foreach ($docTypes as $type) {
+ if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) {
+ self::returnJson([
+ 'success' => false,
+ 'message' => 'Pflichtdokumentation fehlt: ' . $type['text'],
+ 'checklistIncomplete' => true
+ ]);
+ return;
+ }
+ }
+
+ $oldStatus = $workorder->status;
+ $workorder->status = 'documented';
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Arbeitsauftrag zur Prüfung eingereicht.',
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ $response = ['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht'];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Update workorder data (cable info)
+ * POST /MobileApp/Workorder/Workorder/updateWorkorderData
+ */
+ public function updateWorkorderDataAction() {
+ // Check idempotency
+ $idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ self::returnJson($cached);
+ return;
+ }
+ }
+
+ $postData = $this->getPostData();
+ $workorderId = intval($postData['workorderId'] ?? 0);
+ $clientTimestamp = intval($postData['clientTimestamp'] ?? 0);
+
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrag nicht gefunden']);
+ return;
+ }
+
+ $journalText = "Zusatzdaten aktualisiert:\n";
+ $changed = false;
+
+ if (isset($postData['cableLength'])) {
+ if ($workorder->cableLength != $postData['cableLength']) {
+ $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$postData['cableLength']}'\n";
+ $workorder->cableLength = $postData['cableLength'];
+ $changed = true;
+ }
+ }
+
+ if (isset($postData['cableType'])) {
+ if ($workorder->cableType != $postData['cableType']) {
+ $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$postData['cableType']}'\n";
+ $workorder->cableType = $postData['cableType'];
+ $changed = true;
+ }
+ }
+
+ if (!$changed) {
+ $response = ['success' => true, 'message' => 'Keine Änderungen vorgenommen'];
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+ self::returnJson($response);
+ return;
+ }
+
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => $journalText,
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ $response = ['success' => true, 'message' => 'Daten gespeichert'];
+
+ if ($idempotencyKey) {
+ $this->setIdempotencyCache($idempotencyKey, $response);
+ }
+
+ self::returnJson($response);
+ }
+
+ /**
+ * Get checklist status for a workorder
+ * GET /MobileApp/Workorder/Workorder/getChecklist?workorderId=X
+ */
+ public function getChecklistAction() {
+ $workorderId = intval($this->request->workorderId ?? 0);
+
+ if (!$workorderId) {
+ self::returnJson(['success' => false, 'message' => 'Arbeitsauftrags-ID fehlt']);
+ return;
+ }
+
+ // Get tenant config for required doc types
+ $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
+ $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : [];
+
+ // Get existing documentation
+ $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]);
+ $uploadedTypes = array_column((array)$docs, 'documentType');
+ $uploadedTypeCounts = array_count_values($uploadedTypes);
+
+ // Build checklist
+ $checklist = [];
+ $completedCount = 0;
+
+ foreach ($docTypes as $type) {
+ $isCompleted = isset($uploadedTypeCounts[$type['value']]) && $uploadedTypeCounts[$type['value']] > 0;
+ if ($isCompleted) $completedCount++;
+
+ $checklist[] = [
+ 'type' => $type['value'],
+ 'text' => $type['text'],
+ 'required' => $type['required'] ?? false,
+ 'completed' => $isCompleted,
+ 'count' => $uploadedTypeCounts[$type['value']] ?? 0,
+ ];
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'checklist' => $checklist,
+ 'completed' => $completedCount,
+ 'total' => count($docTypes),
+ 'allRequired' => $this->allRequiredCompleted($checklist)
+ ]);
+ }
+
+ // =====================
+ // OFFLINE SYNC ENDPOINTS
+ // =====================
+
+ /**
+ * Get all workorders for offline mode initial sync
+ * Returns full workorder data with details for caching
+ * POST /MobileApp/Workorder/Workorder/getAllForOffline
+ */
+ public function getAllForOfflineAction() {
+ $postData = $this->getPostData();
+ $lastSyncTimestamp = intval($postData['lastSyncTimestamp'] ?? 0);
+
+ // Get company for current user
+ $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
+ if (!$company) {
+ self::returnJson([
+ 'success' => true,
+ 'workorders' => [],
+ 'workorderDetails' => [],
+ 'tenantConfigs' => [],
+ 'serverTimestamp' => time(),
+ 'isFullSync' => true
+ ]);
+ return;
+ }
+
+ // Get all workorders for this company (active statuses only)
+ $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested',
+ 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed',
+ 'problem_solved', 'documented'];
+
+ $workorders = WorkorderModel::getCompanyWorkorders(
+ ['status' => $activeStatuses],
+ 9999, // Large limit for offline sync
+ 0,
+ ['key' => 'deadlineDate', 'order' => 'ASC'],
+ $company->id
+ );
+
+ $result = [];
+ $workorderDetails = [];
+ $tenantConfigCache = [];
+
+ foreach ($workorders as $wo) {
+ $woId = $wo['id'];
+ $result[] = $this->transformWorkorder($wo);
+
+ // Get detailed data for each workorder
+ $detailData = $this->getWorkorderWithDetails($woId, $company->id);
+ if ($detailData) {
+ $detail = [
+ 'workorderId' => $woId,
+ 'workorder' => $this->transformWorkorder($detailData, true),
+ 'lastFetched' => time()
+ ];
+
+ // Get tenant config (cached by addressId)
+ $tenantConfig = $this->getTenantConfigFromWorkorder($woId);
+ if ($tenantConfig) {
+ $configAddressId = $tenantConfig->addressId;
+ if (!isset($tenantConfigCache[$configAddressId])) {
+ $tenantConfigCache[$configAddressId] = [
+ 'addressId' => $configAddressId,
+ 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true) ?? [],
+ 'civilEngineeringDocsRequired' => (bool)$tenantConfig->civilEngineeringDocsRequired,
+ 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true) ?? [],
+ 'requireCableLength' => (bool)$tenantConfig->requireCableLength,
+ 'requireCableType' => (bool)$tenantConfig->requireCableType,
+ 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
+ 'lastFetched' => time()
+ ];
+ }
+ $detail['tenantConfigAddressId'] = $configAddressId;
+ }
+
+ // Get documentation metadata (without full file data)
+ $docs = WorkorderDocumentationModel::getAll(
+ ['workorderId' => $woId],
+ null, 0,
+ ['key' => 'create', 'order' => 'ASC']
+ );
+
+ $docMeta = [];
+ foreach ($docs as $doc) {
+ $file = new File($doc->fileId);
+ $docMeta[] = [
+ 'id' => $doc->id,
+ 'fileId' => $doc->fileId,
+ 'documentType' => $doc->documentType,
+ 'description' => $doc->description,
+ 'mimetype' => $file->mimetype ?? 'application/octet-stream',
+ 'create' => $doc->create,
+ 'thumbnailUrl' => "/MobileApp/Workorder/Workorder/getThumbnail?fileId={$doc->fileId}",
+ 'previewUrl' => "/File/Download/{$doc->fileId}",
+ ];
+ }
+ $detail['documentation'] = $docMeta;
+
+ // Get journals
+ $journals = WorkorderJournalModel::getAll(
+ ['workorderId' => $woId],
+ null, 0,
+ ['key' => 'create', 'order' => 'DESC']
+ );
+
+ $journalData = [];
+ foreach ($journals as $journal) {
+ $journalData[] = [
+ 'id' => $journal->id,
+ 'text' => $journal->text,
+ 'statusChange' => $journal->statusChange ?? null,
+ 'createByName' => UserModel::getOne($journal->createBy)->name ?? 'Unbekannt',
+ 'create' => $journal->create,
+ ];
+ }
+ $detail['journals'] = $journalData;
+
+ $workorderDetails[] = $detail;
+ }
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'workorders' => $result,
+ 'workorderDetails' => $workorderDetails,
+ 'tenantConfigs' => array_values($tenantConfigCache),
+ 'serverTimestamp' => time(),
+ 'isFullSync' => $lastSyncTimestamp === 0
+ ]);
+ }
+
+ /**
+ * Get list of workorder IDs assigned to this company
+ * Used for reassignment detection
+ * GET /MobileApp/Workorder/Workorder/getWorkorderIds
+ */
+ public function getWorkorderIdsAction() {
+ $company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
+ if (!$company) {
+ self::returnJson(['success' => true, 'workorderIds' => [], 'serverTimestamp' => time()]);
+ return;
+ }
+
+ $activeStatuses = ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested',
+ 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed',
+ 'problem_solved', 'documented'];
+
+ $db = $this->db();
+ $fronkDbName = FRONKDB_DBNAME;
+
+ $statusIn = "'" . implode("','", $activeStatuses) . "'";
+ $sql = "SELECT id FROM `{$fronkDbName}`.`Workorder`
+ WHERE companyId = " . intval($company->id) . "
+ AND status IN ({$statusIn})";
+
+ $result = $db->query($sql);
+ $ids = [];
+ while ($row = $result->fetch_assoc()) {
+ $ids[] = intval($row['id']);
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'workorderIds' => $ids,
+ 'serverTimestamp' => time()
+ ]);
+ }
+
+ /**
+ * Process batch of queued operations from offline sync
+ * POST /MobileApp/Workorder/Workorder/syncBatch
+ */
+ public function syncBatchAction() {
+ $postData = $this->getPostData();
+ $operations = $postData['operations'] ?? [];
+
+ if (empty($operations)) {
+ self::returnJson(['success' => true, 'results' => [], 'processedCount' => 0]);
+ return;
+ }
+
+ $results = [];
+ $processedCount = 0;
+
+ foreach ($operations as $op) {
+ $idempotencyKey = $op['idempotencyKey'] ?? null;
+ $operationType = $op['type'] ?? '';
+ $payload = $op['payload'] ?? [];
+ $clientTimestamp = intval($op['clientTimestamp'] ?? 0);
+
+ // Check idempotency cache
+ if ($idempotencyKey) {
+ $cached = $this->getIdempotencyCache($idempotencyKey);
+ if ($cached !== null) {
+ $results[] = [
+ 'idempotencyKey' => $idempotencyKey,
+ 'success' => true,
+ 'cached' => true,
+ 'result' => $cached
+ ];
+ $processedCount++;
+ continue;
+ }
+ }
+
+ // Process operation
+ $opResult = $this->processOfflineOperation($operationType, $payload, $clientTimestamp);
+
+ // Store in idempotency cache
+ if ($idempotencyKey && $opResult['success']) {
+ $this->setIdempotencyCache($idempotencyKey, $opResult);
+ }
+
+ $results[] = [
+ 'idempotencyKey' => $idempotencyKey,
+ 'success' => $opResult['success'],
+ 'cached' => false,
+ 'result' => $opResult,
+ 'error' => $opResult['error'] ?? null
+ ];
+
+ if ($opResult['success']) {
+ $processedCount++;
+ }
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'results' => $results,
+ 'processedCount' => $processedCount,
+ 'totalOperations' => count($operations),
+ 'serverTimestamp' => time()
+ ]);
+ }
+
+ /**
+ * Process a single offline operation
+ */
+ private function processOfflineOperation($type, $payload, $clientTimestamp) {
+ try {
+ switch ($type) {
+ case 'ADD_JOURNAL':
+ return $this->processAddJournal($payload, $clientTimestamp);
+
+ case 'UPDATE_NOTES':
+ return $this->processUpdateNotes($payload, $clientTimestamp);
+
+ case 'SCHEDULE_APPOINTMENT':
+ return $this->processScheduleAppointment($payload, $clientTimestamp);
+
+ case 'REQUEST_INTERVENTION':
+ return $this->processRequestIntervention($payload, $clientTimestamp);
+
+ case 'UPDATE_CABLE_DATA':
+ return $this->processUpdateCableData($payload, $clientTimestamp);
+
+ case 'COMPLETE_WORKORDER':
+ return $this->processCompleteWorkorder($payload, $clientTimestamp);
+
+ default:
+ return ['success' => false, 'error' => 'Unknown operation type: ' . $type];
+ }
+ } catch (Exception $e) {
+ return ['success' => false, 'error' => $e->getMessage()];
+ }
+ }
+
+ private function processAddJournal($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+ $text = trim($payload['text'] ?? '');
+
+ if (!$workorderId || !$text) {
+ return ['success' => false, 'error' => 'Missing required fields'];
+ }
+
+ $journalId = WorkorderJournalModel::create([
+ 'workorderId' => $workorderId,
+ 'text' => $text,
+ 'createBy' => $this->user->id,
+ 'create' => $clientTimestamp ?: time()
+ ]);
+
+ return ['success' => true, 'journalId' => $journalId];
+ }
+
+ private function processUpdateNotes($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+
+ if (!$workorderId) {
+ return ['success' => false, 'error' => 'Missing workorderId'];
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ return ['success' => false, 'error' => 'Workorder not found'];
+ }
+
+ $oldInfo = $workorder->additionalInfo;
+ $newInfo = $payload['additionalInfo'] ?? null;
+
+ // Conflict resolution: concatenate if server has newer changes
+ // For now, we use last-write-wins but log the change
+ $workorder->additionalInfo = $newInfo;
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => "Zusatzinfo geändert (Offline-Sync).\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ return ['success' => true, 'newInfo' => $newInfo];
+ }
+
+ private function processScheduleAppointment($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+ $appointmentDate = intval($payload['appointmentDate'] ?? 0);
+
+ if (!$workorderId || !$appointmentDate) {
+ return ['success' => false, 'error' => 'Missing required fields'];
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ return ['success' => false, 'error' => 'Workorder not found'];
+ }
+
+ $workorder->appointmentDate = $appointmentDate;
+ $workorder->status = 'scheduled';
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $appointmentDate) . ' (Offline-Sync)',
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ return ['success' => true, 'appointmentDate' => $appointmentDate];
+ }
+
+ private function processRequestIntervention($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+ $journalText = trim($payload['journalText'] ?? '');
+ $interventionType = $payload['interventionType'] ?? '';
+
+ if (!$workorderId || !$journalText) {
+ return ['success' => false, 'error' => 'Missing required fields'];
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ return ['success' => false, 'error' => 'Workorder not found'];
+ }
+
+ $oldStatus = $workorder->status;
+ $workorder->status = 'intervention_required';
+ WorkorderModel::update((array)$workorder);
+
+ $fullText = $interventionType ? "{$interventionType}: {$journalText}" : "Eingriff erforderlich: {$journalText}";
+ $fullText .= ' (Offline-Sync)';
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => $fullText,
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ return ['success' => true, 'newStatus' => 'intervention_required'];
+ }
+
+ private function processUpdateCableData($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+
+ if (!$workorderId) {
+ return ['success' => false, 'error' => 'Missing workorderId'];
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ return ['success' => false, 'error' => 'Workorder not found'];
+ }
+
+ $journalText = "Zusatzdaten aktualisiert (Offline-Sync):\n";
+ $changed = false;
+
+ if (isset($payload['cableLength'])) {
+ if ($workorder->cableLength != $payload['cableLength']) {
+ $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$payload['cableLength']}'\n";
+ $workorder->cableLength = $payload['cableLength'];
+ $changed = true;
+ }
+ }
+
+ if (isset($payload['cableType'])) {
+ if ($workorder->cableType != $payload['cableType']) {
+ $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$payload['cableType']}'\n";
+ $workorder->cableType = $payload['cableType'];
+ $changed = true;
+ }
+ }
+
+ if ($changed) {
+ WorkorderModel::update((array)$workorder);
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => $journalText,
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+ }
+
+ return ['success' => true, 'changed' => $changed];
+ }
+
+ private function processCompleteWorkorder($payload, $clientTimestamp) {
+ $workorderId = intval($payload['workorderId'] ?? 0);
+
+ if (!$workorderId) {
+ return ['success' => false, 'error' => 'Missing workorderId'];
+ }
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) {
+ return ['success' => false, 'error' => 'Workorder not found'];
+ }
+
+ // Validate cable data if required
+ $tenantConfig = $this->getTenantConfigFromWorkorder($workorderId);
+ if ($tenantConfig) {
+ if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) {
+ return ['success' => false, 'error' => 'Cable length required', 'validationError' => true];
+ }
+ if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) {
+ return ['success' => false, 'error' => 'Cable type required', 'validationError' => true];
+ }
+ }
+
+ // Validate checklist
+ $docTypes = $tenantConfig ? json_decode($tenantConfig->documentationTypes, true) : [];
+ $docs = WorkorderDocumentationModel::getAll(['workorderId' => $workorderId]);
+ $uploadedTypes = array_column((array)$docs, 'documentType');
+ $uploadedTypeCounts = array_count_values($uploadedTypes);
+
+ foreach ($docTypes as $type) {
+ if (($type['required'] ?? false) && empty($uploadedTypeCounts[$type['value']])) {
+ return [
+ 'success' => false,
+ 'error' => 'Required documentation missing: ' . $type['text'],
+ 'validationError' => true,
+ 'checklistIncomplete' => true
+ ];
+ }
+ }
+
+ $oldStatus = $workorder->status;
+ $workorder->status = 'documented';
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Arbeitsauftrag zur Prüfung eingereicht (Offline-Sync).',
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('documented'),
+ 'create' => $clientTimestamp ?: time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ return ['success' => true, 'newStatus' => 'documented'];
+ }
+
+ /**
+ * Get thumbnail for documentation image
+ * GET /MobileApp/Workorder/Workorder/getThumbnail?fileId=X
+ */
+ public function getThumbnailAction() {
+ $fileId = intval($this->request->fileId ?? 0);
+
+ if (!$fileId) {
+ header('HTTP/1.1 400 Bad Request');
+ echo 'File ID required';
+ return;
+ }
+
+ $file = new File($fileId);
+ if (!$file->id) {
+ header('HTTP/1.1 404 Not Found');
+ echo 'File not found';
+ return;
+ }
+
+ // Check if it's an image
+ $mimetype = $file->mimetype ?? '';
+ if (strpos($mimetype, 'image/') !== 0) {
+ // For non-images, redirect to download
+ header('Location: /File/Download/' . $fileId);
+ return;
+ }
+
+ // Generate thumbnail path
+ $thumbDir = DATADIR . 'thumbnails/workorder/';
+ if (!is_dir($thumbDir)) {
+ mkdir($thumbDir, 0755, true);
+ }
+
+ $thumbPath = $thumbDir . $fileId . '_200x200.jpg';
+
+ // Generate thumbnail if it doesn't exist
+ if (!file_exists($thumbPath)) {
+ $sourcePath = $file->getFilepath();
+ if (!$sourcePath || !file_exists($sourcePath)) {
+ header('HTTP/1.1 404 Not Found');
+ echo 'Source file not found';
+ return;
+ }
+
+ // Create thumbnail
+ $this->createThumbnail($sourcePath, $thumbPath, 200, 200, $mimetype);
+ }
+
+ // Serve the thumbnail
+ if (file_exists($thumbPath)) {
+ header('Content-Type: image/jpeg');
+ header('Cache-Control: public, max-age=31536000'); // Cache for 1 year
+ header('Content-Length: ' . filesize($thumbPath));
+ readfile($thumbPath);
+ } else {
+ // Fallback to original
+ header('Location: /File/Download/' . $fileId);
+ }
+ }
+
+ /**
+ * Create a thumbnail from an image
+ */
+ private function createThumbnail($sourcePath, $destPath, $maxWidth, $maxHeight, $mimetype) {
+ // Get source image info
+ $imageInfo = getimagesize($sourcePath);
+ if (!$imageInfo) {
+ return false;
+ }
+
+ $srcWidth = $imageInfo[0];
+ $srcHeight = $imageInfo[1];
+
+ // Calculate new dimensions
+ $ratio = min($maxWidth / $srcWidth, $maxHeight / $srcHeight);
+ $newWidth = round($srcWidth * $ratio);
+ $newHeight = round($srcHeight * $ratio);
+
+ // Create source image resource
+ switch ($mimetype) {
+ case 'image/jpeg':
+ case 'image/jpg':
+ $srcImage = imagecreatefromjpeg($sourcePath);
+ break;
+ case 'image/png':
+ $srcImage = imagecreatefrompng($sourcePath);
+ break;
+ case 'image/gif':
+ $srcImage = imagecreatefromgif($sourcePath);
+ break;
+ case 'image/webp':
+ $srcImage = imagecreatefromwebp($sourcePath);
+ break;
+ default:
+ return false;
+ }
+
+ if (!$srcImage) {
+ return false;
+ }
+
+ // Create destination image
+ $destImage = imagecreatetruecolor($newWidth, $newHeight);
+
+ // Preserve transparency for PNG
+ if ($mimetype === 'image/png') {
+ imagealphablending($destImage, false);
+ imagesavealpha($destImage, true);
+ $transparent = imagecolorallocatealpha($destImage, 255, 255, 255, 127);
+ imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $transparent);
+ } else {
+ // White background for other formats
+ $white = imagecolorallocate($destImage, 255, 255, 255);
+ imagefilledrectangle($destImage, 0, 0, $newWidth, $newHeight, $white);
+ }
+
+ // Resize
+ imagecopyresampled(
+ $destImage, $srcImage,
+ 0, 0, 0, 0,
+ $newWidth, $newHeight,
+ $srcWidth, $srcHeight
+ );
+
+ // Save as JPEG
+ $result = imagejpeg($destImage, $destPath, 85);
+
+ // Clean up
+ imagedestroy($srcImage);
+ imagedestroy($destImage);
+
+ return $result;
+ }
+
+ // =====================
+ // IDEMPOTENCY HELPERS
+ // =====================
+
+ /**
+ * Get cached idempotency response
+ */
+ private function getIdempotencyCache($key) {
+ $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key;
+
+ // Try APC cache first (if available)
+ if (function_exists('apcu_fetch')) {
+ $success = false;
+ $data = apcu_fetch($cacheKey, $success);
+ if ($success) {
+ return $data;
+ }
+ }
+
+ // Fall back to file cache
+ $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json';
+ if (file_exists($cachePath)) {
+ $data = json_decode(file_get_contents($cachePath), true);
+ if ($data && isset($data['expires']) && $data['expires'] > time()) {
+ return $data['response'];
+ }
+ // Expired, delete
+ unlink($cachePath);
+ }
+
+ return null;
+ }
+
+ /**
+ * Store idempotency response in cache
+ */
+ private function setIdempotencyCache($key, $response) {
+ $cacheKey = self::IDEMPOTENCY_CACHE_PREFIX . $key;
+
+ // Try APC cache first (if available)
+ if (function_exists('apcu_store')) {
+ apcu_store($cacheKey, $response, self::IDEMPOTENCY_TTL);
+ return;
+ }
+
+ // Fall back to file cache
+ $cachePath = sys_get_temp_dir() . '/' . $cacheKey . '.json';
+ $data = [
+ 'response' => $response,
+ 'expires' => time() + self::IDEMPOTENCY_TTL
+ ];
+ file_put_contents($cachePath, json_encode($data));
+ }
+
+ // =====================
+ // HELPER METHODS
+ // =====================
+
+ /**
+ * Transform workorder for API response
+ * @param array|object $wo Workorder data (can be array or object from getCompanyWorkorders)
+ * @param bool $detailed Include full customer details
+ */
+ private function transformWorkorder($wo, $detailed = false) {
+ // Handle both array and object formats
+ $isArray = is_array($wo);
+ $get = function($key, $default = null) use ($wo, $isArray) {
+ if ($isArray) {
+ return $wo[$key] ?? $default;
+ }
+ return $wo->$key ?? $default;
+ };
+
+ // Customer name: use company if available, else customerName (firstname lastname)
+ $customerCompany = $get('customerCompany', '');
+ $customerName = $customerCompany ?: $get('customerName', '');
+
+ // Build address from the joined data
+ $street = $get('street', '');
+ $hausnummer = $get('hausnummer', '');
+ $plz = $get('plz', '');
+ $city = $get('city', '');
+ $customerAddress = trim("{$street} {$hausnummer}, {$plz} {$city}", ', ');
+
+ $status = $get('status');
+ $appointmentDate = $get('appointmentDate');
+ $deadlineDate = $get('deadlineDate');
+ $cableType = $get('cableType', '');
+ $cableLength = $get('cableLength', '');
+
+ $result = [
+ 'id' => intval($get('id', 0)),
+ 'fcpName' => $get('rimo_fcp_name', ''),
+ 'oaid' => $get('oaid', ''),
+ 'status' => $status,
+ 'statusText' => $this->statusOptions[$status]['text'] ?? $status,
+ 'statusColor' => $this->statusOptions[$status]['color'] ?? 'secondary',
+ 'customerName' => $customerName,
+ 'customerAddress' => $customerAddress,
+ 'additionalInfo' => $get('additionalInfo', ''),
+ 'appointmentDate' => $appointmentDate ? intval($appointmentDate) : null,
+ 'appointmentFormatted' => $appointmentDate ? date('d.m.Y H:i', $appointmentDate) : null,
+ 'deadlineDate' => $deadlineDate ? intval($deadlineDate) : null,
+ 'deadlineFormatted' => $deadlineDate ? date('d.m.Y', $deadlineDate) : null,
+ 'cableType' => $cableType,
+ 'cableLength' => $cableLength,
+ 'hasCableFlag' => !empty($cableType) || !empty($cableLength),
+ ];
+
+ // For detailed view (single workorder), include customer contact info
+ if ($detailed) {
+ $result['customer'] = [
+ 'id' => intval($get('id', 0)),
+ 'name' => $customerName,
+ 'street' => trim("{$street} {$hausnummer}"),
+ 'zip' => $plz,
+ 'city' => $city,
+ 'phone' => $get('phone', ''),
+ 'email' => $get('email', ''),
+ 'gpsLat' => null, // Not available in this query
+ 'gpsLng' => null,
+ ];
+
+ $result['campaign'] = $get('networkOwnerName', '');
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get status display text
+ */
+ private function getStatusText($statusKey) {
+ return $this->statusOptions[$statusKey]['text'] ?? ucfirst(str_replace('_', ' ', $statusKey));
+ }
+
+ /**
+ * Get tenant config from workorder
+ */
+ private function getTenantConfigFromWorkorder($workorderId) {
+ if (!$workorderId) return null;
+
+ $workorder = WorkorderModel::get($workorderId);
+ if (!$workorder) return null;
+
+ $preorder = new Preorder($workorder->preorderId);
+ if (!$preorder->id) return null;
+
+ $campaign = new Preordercampaign($preorder->preordercampaign_id);
+ if (!$campaign->id) return null;
+
+ $network = NetworkModel::getOne($campaign->network_id);
+ if (!$network) return null;
+
+ return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]);
+ }
+
+ /**
+ * Save documentation file
+ */
+ private function saveDocumentation($workorderId, $documentType, $description) {
+ try {
+ $uploaded = mfUpload::handleFormUpload("file", false, "/Workorder");
+ WorkorderDocumentationModel::create([
+ 'workorderId' => $workorderId,
+ 'fileId' => $uploaded->id,
+ 'description' => $description,
+ 'documentType' => $documentType,
+ 'create' => time(),
+ 'createBy' => $this->user->id
+ ]);
+ } catch (Exception $e) {
+ // Log error if necessary
+ }
+ }
+
+ /**
+ * Check if all required checklist items are completed
+ */
+ private function allRequiredCompleted($checklist) {
+ foreach ($checklist as $item) {
+ if ($item['required'] && !$item['completed']) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get single workorder with full joined data (same structure as getCompanyWorkorders)
+ */
+ private function getWorkorderWithDetails($workorderId, $companyId) {
+ $db = $this->db();
+ $fronkDbName = FRONKDB_DBNAME;
+ $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
+
+ $sql = "
+ SELECT w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo,
+ w.cableType, w.cableLength, hn.rimo_fcp_name,
+ owner_addr.company as networkOwnerName, p.preordercampaign_id,
+ CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.company as customerCompany, p.oaid,
+ p.phone, p.email, str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment,
+ plz.plz, ort.name as city
+ FROM `{$fronkDbName}`.`Workorder` w
+ JOIN `{$fronkDbName}`.`Preorder` p ON w.preorderId = p.id
+ LEFT JOIN `{$fronkDbName}`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
+ LEFT JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id
+ LEFT JOIN `{$fronkDbName}`.`Address` owner_addr ON n.owner_id = owner_addr.id
+ LEFT JOIN `{$addressDbName}`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
+ LEFT JOIN `{$addressDbName}`.`Strasse` str ON hn.strasse_id = str.id
+ LEFT JOIN `{$addressDbName}`.`Plz` plz ON hn.plz_id = plz.id
+ LEFT JOIN `{$addressDbName}`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
+ LEFT JOIN `{$addressDbName}`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
+ WHERE w.id = " . intval($workorderId) . "
+ AND w.companyId = " . intval($companyId) . "
+ LIMIT 1
+ ";
+
+ $result = $db->query($sql);
+ return $result ? $result->fetch_assoc() : null;
+ }
+}
diff --git a/application/MobileApp/Shared/MobileAppBaseHandler.php b/application/MobileApp/Shared/MobileAppBaseHandler.php
new file mode 100644
index 000000000..508d77e7d
--- /dev/null
+++ b/application/MobileApp/Shared/MobileAppBaseHandler.php
@@ -0,0 +1,113 @@
+request = $request;
+ $this->user = $user;
+ $this->controller = $controller;
+ }
+
+ /**
+ * Check if user has required permission
+ * @return bool
+ */
+ public function checkPermission() {
+ // If no permission required, allow access
+ if (!$this->requiredPermission) {
+ return true;
+ }
+
+ // If no user, deny access
+ if (!$this->user || !$this->user->id) {
+ return false;
+ }
+
+ // Check permission
+ return $this->user->can($this->requiredPermission);
+ }
+
+ /**
+ * Render the app view
+ * Override in subclass if custom rendering needed
+ */
+ public function renderView() {
+ $layout = $this->controller->layout();
+
+ // Set template
+ if ($this->viewTemplate) {
+ $layout->setTemplate($this->viewTemplate);
+ } else {
+ $layout->setTemplate("MobileApp/{$this->appName}");
+ }
+
+ // Set default JS globals
+ $layout->set("JSGlobals", $this->getJSGlobals());
+ }
+
+ /**
+ * Get JS globals to pass to frontend
+ * Override in subclass to add app-specific globals
+ */
+ protected function getJSGlobals() {
+ $globals = [
+ 'BASE_PATH' => '/MobileApp/' . $this->appName,
+ 'APP_NAME' => $this->appName,
+ ];
+
+ if ($this->user && $this->user->id) {
+ $globals['USER_ID'] = $this->user->id;
+ $globals['USER_NAME'] = $this->user->name;
+ }
+
+ return $globals;
+ }
+
+ /**
+ * Return JSON response (shorthand)
+ */
+ protected static function returnJson($data, $statusCode = 200) {
+ mfBaseController::returnJson($data, $statusCode);
+ }
+
+ /**
+ * Get POST data from JSON body
+ */
+ protected function getPostData() {
+ return json_decode(file_get_contents('php://input'), true) ?? [];
+ }
+
+ /**
+ * Get database instance
+ */
+ protected function db() {
+ return FronkDB::singleton();
+ }
+}
diff --git a/application/OpenAccessId/OpenAccessId.php b/application/OpenAccessId/OpenAccessId.php
index 04f70a191..35ae7fff3 100644
--- a/application/OpenAccessId/OpenAccessId.php
+++ b/application/OpenAccessId/OpenAccessId.php
@@ -173,7 +173,12 @@ class OpenAccessId extends mfBaseModel {
$resp_data = Rimoapi::assignOaid($this->oaid, $ftu_data['id']);
// update OAID export data
- $exp_data_update = json_decode($this->export_data);
+
+ //$exp_data_update = json_decode($this->export_data);
+ $exp_data_update = $this->getExportData();
+ if(!property_exists($exp_data_update, "rimo")) {
+ $exp_data_update->rimo = new StdClass();
+ }
$exp_data_update->rimo->ftu_id = $ftu_data['id'];
$exp_data_update->rimo->ftu_name = $ftu_data['name'];
$exp_data_update->rimo->ftu_assigned_date = date("U");
@@ -306,11 +311,11 @@ class OpenAccessId extends mfBaseModel {
public function getExportData($key = false) {
if(!$this->export_data) {
- return [];
+ return new StdClass();
} else {
$exdata = json_decode($this->export_data);
if(!is_object($exdata)) {
- return [];
+ return new StdClass();
}
if(!$key) {
diff --git a/application/Order/Order.php b/application/Order/Order.php
index 1774f35fb..00960d6a5 100644
--- a/application/Order/Order.php
+++ b/application/Order/Order.php
@@ -67,6 +67,27 @@ class Order extends mfBaseModel {
//var_dump($this->terminations);exit;
return $terminations;
}
+
+ public function getSnoppProduct() {
+ foreach($this->getProperty("products") as $product) {
+ if($product->snopp_order_id) return $product;
+ }
+ return null;
+ }
+
+ public function getOaidProduct() {
+ foreach($this->getProperty("products") as $product) {
+ if($product->oaid) return $product;
+ }
+ return null;
+ }
+
+ public function getPreorderProduct() {
+ foreach($this->getProperty("products") as $product) {
+ if($product->preorder_id) return $product;
+ }
+ return null;
+ }
public function getShippingdate() {
if(!$this->id) {
diff --git a/application/Order/OrderController.php b/application/Order/OrderController.php
index c8da669e9..09c88cd3c 100644
--- a/application/Order/OrderController.php
+++ b/application/Order/OrderController.php
@@ -359,7 +359,7 @@ class OrderController extends mfBaseController {
return $new_filter;
}
- protected function addAction() {
+ public function addAction() {
//var_dump($this->request->filter);exit;
@@ -393,9 +393,9 @@ class OrderController extends mfBaseController {
$products[$pn->product_id] = $pn->product;
}
}
-
+
}
-
+
$order = $this->layout()->get("order");
if($order) {
foreach($order->products as $op) {
@@ -404,7 +404,7 @@ class OrderController extends mfBaseController {
}
}
}
-
+
$this->layout()->set("products", $products);
$countries = CountryModel::getAll();
@@ -969,6 +969,12 @@ class OrderController extends mfBaseController {
}
$product_data = [];
+ if(array_key_exists("preorder_id", $p) && $p["preorder_id"]) {
+ $product_data["preorder_id"] = $p["preorder_id"];
+ }
+ if(array_key_exists("oaid", $p) && $p["oaid"]) {
+ $product_data["oaid"] = $p["oaid"];
+ }
$product_data["order_id"] = $new_id;
$product_data["product_id"] = $p["product_id"];
$product_data['amount'] = (!empty($p['amount'])) ? $p['amount'] : 1;
@@ -1338,7 +1344,7 @@ class OrderController extends mfBaseController {
$this->layout()->setFlash("Keine Berechtigung", "error");
$this->redirect("Order");
}
-
+
$r = $this->request;
$order_id = $r->id;
@@ -1366,7 +1372,146 @@ class OrderController extends mfBaseController {
$this->returnJson(["status" => "OK", "order" => ['id' => $order_id]]);
}
-
+
+ protected function createSnoppOrderAction() {
+ $order_id = $this->request->id;
+
+ if(!$order_id || $order_id < 1) {
+ $this->layout()->setFlash("Bestellung nicht gefunden.", "error");
+ $this->redirect("Order");
+ }
+ $order = new Order($order_id);
+ if(!$order->id) {
+ $this->layout()->setFlash("Bestellung nicht gefunden.", "error");
+ $this->redirect("Order");
+ }
+
+ $order_product = false;
+
+ $product_snopp_id = false;
+ $products_noterm = false;
+
+ // find product
+ foreach($order->products as $op) {
+ // check for valid internet access product
+ if(!in_array($op->product->producttech_id, TT_PRODUCTTECH_IDS_INTERNET_ACCESSS)) {
+ continue;
+ }
+
+ if($op->oaid) {
+ $order_product = $op;
+ break;
+ }
+ if($op->preorder_id) {
+ $order_product = $op;
+ break;
+ }
+ // if product has a snopp product_id, then this must be it
+ if($op->product->getAttributeValue("oan_pid_snopp")) {
+ $order_product = $op;
+ break;
+ }
+ }
+
+ if(!$order_product) {
+ $this->layout()->setFlash("Kein für SNOPP-Bestellungen geeignetes Produkt in dieser Bestellung gefunden.", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ // order in snopp
+ $snopp_prod_id = $op->product->getAttributeValue("oan_pid_snopp");
+ if(!$snopp_prod_id) {
+ $this->layout()->setFlash("SNOPP Product ID fehlt im Produkt (".$order_product->product->name.").", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ // find snopp api credentials
+ $api_creds = $order_product->product->getOwnerSnoppApiCredentials();
+ $this->log->debug(__METHOD__.": Snopp Api Creds: ".print_r($api_creds, true));
+ if(!$api_creds) {
+ $this->layout()->setFlash("Produktbesitzer hat keinen SNOPP Api Key", "error");
+ $this->redirect("Order");
+ }
+ $baseurl = $api_creds["prod"]["url"];
+ $apikey = $api_creds["prod"]["key"];
+ $snopp = new Snoppapi($baseurl, $apikey);
+
+ $ext_id = false;
+
+ if($order_product->oaid) {
+ $ext_id = $order_product->oaid;
+ } elseif($order_product->preorder_id) {
+ $preorder = new Preorder($order_product->preorder_id);
+ if($preorder->id && $preorder->campaign->fulfillment == "citycom_oan") {
+ $ext_id = "SDIHome_xtc{$preorder->adb_wohneinheit_id}_1700000000";
+ } elseif($preorder->id) {
+ if($preorder->adb_wohneinheit->oaid) {
+ $ext_id = $preorder->adb_wohneinheit->oaid;
+ } elseif($preorder->adb_wohneinheit->extref) {
+ $ext_id = $preorder->adb_wohneinheit->extref;
+ }
+ }
+ } else {
+ // search for address in snopp
+ $search_data = [
+ "street" => $order->owner->street,
+ "zip" => $order->owner->zip,
+ "city" => $order->owner->city,
+ ];
+ $homes = $snopp->searchAddress($search_data);
+ if(!$homes) {
+ $this->layout()->setFlash("Home in Snopp nicht gefunden", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ $home = reset($homes);
+ $ext_id = $home->oan_id;
+ }
+
+ if(!$ext_id) {
+ $this->layout()->setFlash("Konnte keine OAID oder External ID zur Adresse finden.", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ $data = [
+ "oan_id" => ($order_product->oaid) ?: $ext_id,
+ "product_id" => $snopp_prod_id,
+ "extref" => ($op->order->partner_number) ?: $op->order->owner->customer_number,
+ "execution_date" => (new DateTime("now"))->setTimezone(new DateTimeZone("Europe/Vienna"))->format("c"),
+ "name" => $order->owner->getCompanyOrName(),
+ "street" => $order->owner->street,
+ "zip" => $order->owner->zip,
+ "city" => $order->owner->city,
+ "phone" => $order->owner->phone,
+ "mobile" => $order->owner->mobile,
+ "email" => $order->owner->email,
+ ];
+
+
+ $resp = $snopp->submitOrder($data);
+
+ if(!$resp) {
+ $this->layout()->setFlash("Fehler beim Bestellen im Snopp.", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ if($resp->status != "Created") {
+ $this->layout()->setFlash("Konnte nicht bestellt werden: '{$resp->result->message}'", "error");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+ }
+
+ if($resp->result->order_id) {
+ $order_product->snopp_order_id = $resp->result->order_id;
+ } else {
+ $order_product->snopp_order_id = 1;
+ }
+ $order_product->save();
+
+ $this->layout()->setFlash("Bestellung erfolgreich in SNOPP erstellt.", "success");
+ $this->redirect("Order", "Index", ["id" => $order->id]);
+
+ }
+
protected function deleteAction() {
if(!$this->me->is(["Admin","salespartner"])) {
$this->layout()->setFlash("Keine Berechtigung", "error");
diff --git a/application/OrderProduct/OrderProduct.php b/application/OrderProduct/OrderProduct.php
index ea1cc5ec1..b8ad6a22b 100644
--- a/application/OrderProduct/OrderProduct.php
+++ b/application/OrderProduct/OrderProduct.php
@@ -48,11 +48,7 @@ class OrderProduct extends mfBaseModel {
public function getProperty($name) {
if($this->$name == null) {
-
- if(!$this->id) {
- return null;
- }
-
+
if($name == "cpeprovisioning") {
$this->cpeprovisioning = CpeprovisioningModel::getFirst(["orderproduct_id" => $this->id]);
return $this->cpeprovisioning;
@@ -128,7 +124,7 @@ class OrderProduct extends mfBaseModel {
}
return $this->editor;
}
-
+
$classname = ucfirst($name);
$idfield = $name."_id";
$this->$name = mfValuecache::singleton()->get("mfObjectmodel-$name-".$this->$idfield);
diff --git a/application/OrderProduct/OrderProductModel.php b/application/OrderProduct/OrderProductModel.php
index e9ab464d6..5b417b684 100644
--- a/application/OrderProduct/OrderProductModel.php
+++ b/application/OrderProduct/OrderProductModel.php
@@ -5,6 +5,8 @@ class OrderProductModel
public $order_id;
public $product_id;
public $termination_id;
+ public $oaid;
+ public $preorder_id;
public $voicenumber;
public $voiceplan_id;
public $domain;
@@ -212,6 +214,13 @@ class OrderProductModel
}
}
+ if (array_key_exists("preorder_id", $filter)) {
+ $preorder_id = $filter['preorder_id'];
+ if (is_numeric($preorder_id)) {
+ $where .= " AND preorder_id=$preorder_id";
+ }
+ }
+
if (array_key_exists("voicenumber", $filter)) {
$voicenumber = FronkDB::singleton()->escape($filter['voicenumber']);
if ($voicenumber) {
diff --git a/application/Preorder/Preorder.php b/application/Preorder/Preorder.php
index fffeb2fbe..b4d18c772 100644
--- a/application/Preorder/Preorder.php
+++ b/application/Preorder/Preorder.php
@@ -26,6 +26,7 @@ class Preorder extends mfBaseModel {
private $statusjournals;
private $cancel_request_status;
private $cancel_request_creator;
+ private $orderproduct;
protected function beforeUpdate($data) {
if(!array_key_exists("edit_by", $data)) {
@@ -736,19 +737,27 @@ class Preorder extends mfBaseModel {
$first_ctag = $search_ctag - ($search_ctag % $ctags_per_home);
$last_ctag = $first_ctag + $ctags_per_home - 1;
+ $mgmt_ctag_exists = false;
+
$mgmt_ctag = null;
$ctag_range = [];
for($i = $first_ctag; $i <= $last_ctag; $i++) {
- if(!PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name])) {
- if($i == $last_ctag) {
+ $ctag = PreorderCtag::getFirstActive(["stag" => $stag, "ctag" => $i, "network" => $network_name]);
+ if(!$ctag) {
+ if($i == $last_ctag && !$mgmt_ctag_exists) {
// mgmt ctag should be the last in range
$mgmt_ctag = $i;
continue;
}
$ctag_range[] = $i;
-
+ } else {
+ if($ctag->service_type == "mgmt") {
+ $this->log->debug(__METHOD__.": mgmt ctag ($i / stag $stag) exists already\n");
+ $mgmt_ctag_exists = true;
+ }
}
}
+
return [$ctag_range, $mgmt_ctag];
}
@@ -1682,6 +1691,13 @@ class Preorder extends mfBaseModel {
return $this->editor;
}
+ if($name == "orderproduct") {
+ $op = OrderProductModel::getFirst(["preorder_id" => $this->id]);
+ if(!$op) return null;
+ $this->orderproduct = $op;
+ return $this->orderproduct;
+ }
+
if($name == "creator") {
$user = mfValuecache::singleton()->get("Worker-id-" . $this->create_by);
if($user) {
diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php
index 2379756eb..52623f44a 100644
--- a/application/Preorder/PreorderController.php
+++ b/application/Preorder/PreorderController.php
@@ -1048,6 +1048,171 @@ class PreorderController extends mfBaseController {
$this->layout()->set("no_filename", false);
}
+ protected function createOrderFromPreorderAction() {
+ $preorder_id = $this->request->preorder_id;
+
+ if(!is_numeric($preorder_id) || $preorder_id < 1) {
+ $this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
+ $this->redirect("Preorder", "Index");
+ }
+
+ $preorder = new Preorder($preorder_id);
+ if(!$preorder->id) {
+ $this->layout()->setFlash("Vorbestellung nicht gefunden!", "error");
+ $this->redirect("Preorder", "Index");
+ }
+
+ $order_data = [];
+ $order_data["preorder_id"] = $preorder->id;
+
+ $owner_data = [];
+ foreach(["company","uid","firstname","lastname","street","zip","city","phone","email"] as $field) {
+ if(!trim($preorder->$field)) {
+ $owner_data[$field] = "";
+ }
+ $owner_data[$field] = trim($preorder->$field);
+ }
+
+ // search owner in Address and add owner_id ...
+ $owner = false;
+ $owners = AddressModel::search($owner_data);
+ foreach($owners as $o) {
+ if(!$this->me->is("employee")) {
+ // external salespartners must not use addresses with customer_number
+ if($o->customer_number) continue;
+ // otherwise use with address
+ $owner = $o;
+ } else {
+ // every address can be used as fallback
+ $owner = $o;
+
+ // if we are employees, customers with customer_number and fibu_primary_account have precedence
+ // but still use addresses with only customer_number as fallback
+ if($o->customer_number) {
+ $owner = $o;
+ if($o->fibu_primary_account) {
+ break;
+ }
+ }
+ }
+ }
+
+ if($owner && $owner->id) {
+ $order_data["owner_id"] = $owner->id;
+ $order_data["owner"] = $owner;
+ } else {
+ foreach($owner_data as $field => $value) {
+ if(!$preorder->$field) continue;
+ $order_data["owner_".$field] = $value;
+ }
+ $order_data["new_owner"] = 1;
+ }
+
+ if($preorder->order_date) {
+ $order_data["order_date"] = $preorder->order_date;
+ } else {
+ $order_data["order_date"] = $preorder->create;
+ }
+
+ $operator = false;
+ $campaign = $preorder->campaign;
+ if(is_array($campaign->active_operators) && count($campaign->active_operators)) {
+ $campaign_operator = reset($campaign->active_operators);
+ $operator = $campaign_operator->operator;
+ }
+
+ if(!$operator) {
+ $this->layout()->setFlash("Kampagne hat keinen Netzbetreiber!", "error");
+ $this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
+ }
+
+ // try product with correct network id
+ $product = ProductModel::getFirst([
+ "external_id" => $operator->id,
+ "network_id" => $campaign->network_id,
+ "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
+ "name" => "%OAN%",
+ "active" => true
+ ]);
+ if(!$product) {
+ // else use any product from operator
+ $product = ProductModel::getFirst([
+ "external_id" => $operator->id,
+ "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
+ "name" => "%OAN%",
+ "active" => true
+ ]);
+ }
+ if(!$product) {
+ // else use any product from operator
+ $product = ProductModel::getFirst([
+ "external_id" => $operator->id,
+ "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
+ "active" => true
+ ]);
+ }
+ if($operator->id == 1) {
+ if(!$product) {
+ $product = ProductModel::getFirst([
+ "external" => 0,
+ "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
+ "network_id" => $campaign->network_id,
+ "active" => true
+ ]);
+ }
+ if(!$product) {
+ $product = ProductModel::getFirst([
+ "external" => 0,
+ "productgroup_id" => TT_PRODUCTGROUP_ID_INTERNET_ACCESS_RESI,
+ "name" => "%OAN%",
+ "active" => true
+ ]);
+ }
+ }
+ //var_dump($product);exit;
+ if(!$product) {
+ $this->layout()->setFlash("Keine Produkte für Netzbetreiber gefunden!", "error");
+ $this->redirect("Preorder", "Index", ["filter" => ["preordercampaign_id" => $campaign->id]], "preorder=$preorder_id");
+ }
+
+ $product_data = [];
+ $product_data["preorder_id"] = $preorder->id;
+ $product_data["oaid"] = $preorder->oaid;
+ $product_data["product_id"] = $product->id;
+ $product_data['amount'] = 1;
+ $product_data["pos"] = 1;
+ $product_data["description"] = "";
+ $product_data["price"] = trim($product->price) ? Layout::commaToDot(trim($product->price)) : 0;
+ $product_data["price_setup"] = trim($product->price_setup) ? Layout::commaToDot(trim($product->price_setup)) : 0;
+
+ $product_data["billing_delay"] = ($product->billing_delay) ? $product->billing_delay : 0;
+ if($product_data["billing_delay"] > 6) {
+ $product_data["billing_delay"] = 6;
+ }
+ $product_data["billing_period"] = $product->billing_period;
+ $product_data["contract_term"] = $product->contract_term;
+
+ if($this->me->is("Admin")) {
+ $product_data["price_nne"] = $product->price_nne;
+ $product_data["price_nbe"] = $product->price_nbe;
+ }
+
+ $order_data["products"] = [1 => OrderProductModel::create($product_data)];
+
+ //var_dump($order_data["products"]);exit;
+ $order = new Order();
+ $order->update($order_data);
+
+ //var_dump($owner_data);exit;
+
+ $oc = new OrderController();
+
+ $this->layout()->set("order", $order);
+ return $oc->addAction();
+
+
+ }
+
protected function apiAction() {
$do = $this->request->do;
$data = [];
@@ -1420,8 +1585,14 @@ class PreorderController extends mfBaseController {
$new_remark = date("d.m.Y").": ".$new_remark;
+ $api_creds = $preorder->getNetownerRimoApiCredentials();
+ $this->log->debug(__METHOD__.": Rimo Api Creds: ".print_r($api_creds, true));
+ if(!$api_creds) return false;
+
+ $apikey = $api_creds["prod"]["key"];
+
// upload remark to Rimo
- if(!Rimoapi::addRemark($workorder->rimo_id, $new_remark)) {
+ if(!Rimoapi::addRemark($apikey, $workorder->rimo_id, $new_remark)) {
return false;
}
diff --git a/application/PreorderBillingInvoice/PreorderBillingInvoice.php b/application/PreorderBillingInvoice/PreorderBillingInvoice.php
index 96b755201..0ca11cae6 100644
--- a/application/PreorderBillingInvoice/PreorderBillingInvoice.php
+++ b/application/PreorderBillingInvoice/PreorderBillingInvoice.php
@@ -6,6 +6,7 @@ use chillerlan\QRCode\Output\QROutputInterface;
class PreorderBillingInvoice extends mfBaseModel {
protected $forcestr = ["company", "zip", "email", "phone"];
+ private $owner;
private $netowner;
private $positions;
private $pdf;
diff --git a/application/PreorderCtag/PreorderCtag.php b/application/PreorderCtag/PreorderCtag.php
index db3f194f0..85f589cda 100644
--- a/application/PreorderCtag/PreorderCtag.php
+++ b/application/PreorderCtag/PreorderCtag.php
@@ -1,10 +1,8 @@
debug($sql);
$res = $db->query($sql);
@@ -230,7 +228,7 @@ class PreorderCtag extends mfBaseModel {
$where = self::getSqlFilter($filter);
$sql = "SELECT PreorderCtag.* FROM PreorderCtag
WHERE $where
- ORDER BY preorder_id DESC,stag DESC,ctag DESC LIMIT 1";
+ ORDER BY ctag DESC LIMIT 1";
//var_dump($sql);exit;
mfLoghandler::singleton()->debug($sql);
$res = $db->query($sql);
diff --git a/application/Preorderlogistics/PreorderlogisticsController.php b/application/Preorderlogistics/PreorderlogisticsController.php
index 3254f53e1..b39cfe0fb 100644
--- a/application/Preorderlogistics/PreorderlogisticsController.php
+++ b/application/Preorderlogistics/PreorderlogisticsController.php
@@ -337,6 +337,16 @@ class PreorderlogisticsController extends mfBaseController {
}
}
+ // Date filter for sent date (Versanddatum range)
+ if (!empty($filter['sent_date']) && is_array($filter['sent_date'])) {
+ if (!empty($filter['sent_date']['from'])) {
+ $new_filter['add-where'] .= " AND Preorderlogistics.sent >= " . intval($filter['sent_date']['from']);
+ }
+ if (!empty($filter['sent_date']['to'])) {
+ $new_filter['add-where'] .= " AND Preorderlogistics.sent <= " . intval($filter['sent_date']['to']);
+ }
+ }
+
$new_filter["status_code"] = 140;
$new_filter["deleted"] = 0;
$new_filter["unit_count<="] = 2;
diff --git a/application/Product/Product.php b/application/Product/Product.php
index 9bbcd6d9d..eff806c3a 100644
--- a/application/Product/Product.php
+++ b/application/Product/Product.php
@@ -54,6 +54,28 @@ class Product extends mfBaseModel {
}
+ public function getAttributeValue($name) {
+ $attributes = $this->getProperty("attributes");
+ if(!array_key_exists($name, $attributes) || !$attributes[$name]->value) {
+ return null;
+ }
+ return $attributes[$name]->value;
+ }
+
+ public function getOwnerSnoppApiCredentials() {
+ $owner = $this->getProperty("owner");
+ if(!$owner) return false;
+
+ foreach(TT_SNOPP_API_CREDS as $api_creds) {
+ if($api_creds["address_id"] == $owner->id) {
+ return $api_creds;
+ }
+ }
+
+ return null;
+ }
+
+
public function getProperty($name) {
if($this->$name == null) {
diff --git a/application/Product/ProductModel.php b/application/Product/ProductModel.php
index eafe20bd4..5cd22046c 100644
--- a/application/Product/ProductModel.php
+++ b/application/Product/ProductModel.php
@@ -105,6 +105,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
+ LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
ORDER BY Productgroup.name,Producttech.name,Product.name LIMIT 1
@@ -135,6 +136,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
+ LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
) as p
@@ -160,6 +162,7 @@ class ProductModel {
LEFT JOIN ProductAttribute ON (ProductAttribute.product_id = Product.id)
LEFT JOIN Producttech ON (Product.producttech_id = Producttech.id)
LEFT JOIN ProducttechAttribute ON (ProducttechAttribute.producttech_id = Producttech.id)
+ LEFT JOIN ProductNetwork ON (ProductNetwork.product_id = Product.id)
WHERE $where
GROUP BY Product.id
ORDER BY Productgroup.name,Producttech.name,Product.name
@@ -169,7 +172,7 @@ class ProductModel {
if(is_array($limit) && count($limit)) {
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
- } elseif(is_numeric($count)) {
+ } elseif(is_numeric($limit['count'])) {
$sql .= " LIMIT ".$limit['count'];
}
}
@@ -232,6 +235,15 @@ class ProductModel {
$where .= " AND Product.sla_id IN (". implode(",", $sla_id).")";
}
}
+
+ if(array_key_exists("network_id", $filter)) {
+ $network_id = $filter['network_id'];
+ if(is_numeric($network_id)) {
+ $where .= " AND ProductNetwork.network_id=$network_id";
+ } elseif(is_array($network_id) && count($network_id)) {
+ $where .= " AND ProductNetwork.network_id IN (". implode(",", $network_id).")";
+ }
+ }
if(array_key_exists("name", $filter)) {
$name = $db->escape($filter['name']);
@@ -284,7 +296,7 @@ class ProductModel {
if(array_key_exists("attributevalue", $filter)) {
$attributevalue = $db->escape($filter['attributevalue']);
- if($attributevalue) {
+ if(strlen($attributevalue)) {
$where .= " AND ProductAttribute.value = '$attributevalue'";
}
}
diff --git a/application/Radius/RadiusController.php b/application/Radius/RadiusController.php
index 5af86bb65..7c8e96be3 100644
--- a/application/Radius/RadiusController.php
+++ b/application/Radius/RadiusController.php
@@ -4,9 +4,45 @@ use PHPMailer\PHPMailer\Exception;
class RadiusController extends mfBaseController {
private User $me;
+ private bool $isApiCall = false;
+
+ private array $apiAllowedActions = [
+ 'ProxyUnsecureHTTPRequestToRadius',
+ 'GenieacsRunSpeedtest',
+ 'GenieacsGetSpeedtestResult',
+ 'GenieacsGetDeviceByIp',
+ 'GenieacsGetDeviceByMac',
+ 'GenieacsRefreshDevice',
+ 'GenieacsRebootDevice',
+ 'GenieacsGetDeviceInfo',
+ 'GenieacsPing',
+ 'GenieacsRemoteAccess',
+ 'GenieacsEventLog',
+ 'GenieacsNetworkStructure',
+ ];
protected function init(): void {
- $this->needlogin=true;
+ $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
+
+ if ($apiKey && in_array($this->action, $this->apiAllowedActions)) {
+ $me = new User();
+ $me->loadByApikey($apiKey);
+
+ if ($me->id) {
+ $this->me = $me;
+ $this->isApiCall = true;
+ $this->needlogin = false;
+ if (!defined('INTERNAL_USER_ID')) {
+ define('INTERNAL_USER_ID', $me->id);
+ }
+ header("Access-Control-Allow-Origin: *");
+ header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
+ header("Access-Control-Allow-Headers: Content-Type, X-API-Key");
+ return;
+ }
+ }
+
+ $this->needlogin = true;
$me = new User();
$me->loadMe();
$this->layout()->set("me", $me);
@@ -51,20 +87,32 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS();
- // Set speedtest parameters on the device
- $acs->setParameterValues($deviceId, [
+ $resolvedId = $this->resolveDeviceId($deviceId, $acs);
+ if (!$resolvedId) self::sendError("Device not found in GenieACS");
+
+ $acs->getParameterValues($resolvedId, [
+ 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start',
+ 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect',
+ 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess',
+ 'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result'
+ ]);
+
+ sleep(2);
+
+ $acs->setParameterValues($resolvedId, [
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Start' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.StartBidirect' => 1,
'InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.WANAccess' => true
]);
- // Get device and extract IP
- $device = $acs->getDevice($deviceId);
- $ip = GenieACS::getExternalIP($device);
+ sleep(3);
+
+ $device = $acs->getDevice($resolvedId);
+ $managementIp = GenieACS::getManagementIP($device);
+ $externalIp = GenieACS::getExternalIP($device);
+ $ip = $externalIp ?: $managementIp;
if (!$ip) self::sendError("Could not determine device IP");
-
- // Trigger speedtest via external API
$url = "http://acs.xinon.at:5000/run-speedtest";
$apiKey = "2H9zWrgxPEJL9MZ1yTGtWh16cPCu0AsQ";
$data = json_encode(['ip' => $ip]);
@@ -84,9 +132,8 @@ class RadiusController extends mfBaseController {
if ($response === false) self::sendError("Failed to connect to speedtest server");
- self::returnJson(['success' => true, 'message' => 'Speedtest started']);
+ self::returnJson(['success' => true, 'message' => 'Speedtest started', 'ip' => $ip, 'serverResponse' => json_decode($response, true)]);
} catch (Exception $e) {
- $this->log->debug("Speedtest Error", ['error' => $e->getMessage()]);
self::sendError("Error running speedtest: " . $e->getMessage());
}
}
@@ -101,11 +148,12 @@ class RadiusController extends mfBaseController {
$acs = $this->getGenieACS();
- // Request parameter refresh
- $acs->getParameterValues($deviceId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
+ $resolvedId = $this->resolveDeviceId($deviceId, $acs);
+ if (!$resolvedId) self::sendError("Device not found in GenieACS");
- // Get device info with full data
- $device = $acs->getDevice($deviceId);
+ $acs->getParameterValues($resolvedId, ['InternetGatewayDevice.X_AVM-DE_SpeedtestServer.UDP.Result']);
+
+ $device = $acs->getDevice($resolvedId);
if (!$device) self::sendError("Device not found");
@@ -178,6 +226,19 @@ class RadiusController extends mfBaseController {
return new GenieACS($host, $username, $password);
}
+ private function resolveDeviceId(string $deviceId, GenieACS $acs): ?string {
+ if (strpos($deviceId, ':') !== false) {
+ $device = $acs->getDeviceByMac($deviceId);
+ if ($device) {
+ $resolvedId = GenieACS::getDeviceId($device);
+ if ($resolvedId) return $resolvedId;
+ if (isset($device['_id'])) return $device['_id'];
+ }
+ return null;
+ }
+ return $deviceId;
+ }
+
protected function genieacsGetDeviceByIpAction() {
try {
$ip = $_GET['ip'] ?? null;
@@ -218,6 +279,73 @@ class RadiusController extends mfBaseController {
}
}
+ protected function genieacsGetDeviceByMacAction() {
+ try {
+ $mac = $_GET['mac'] ?? null;
+ $this->log->debug("genieacsGetDeviceByMacAction", ['mac' => $mac]);
+ if (!$mac) self::sendError("MAC address is required");
+
+ $acs = $this->getGenieACS();
+ $matchedDevice = $acs->getDeviceByMac($mac);
+
+ if (!$matchedDevice) {
+ self::returnJson(['success' => false, 'message' => 'No device found with this MAC address']);
+ return;
+ }
+
+ self::returnJson([
+ 'success' => true,
+ 'deviceId' => GenieACS::getDeviceId($matchedDevice),
+ 'deviceInfo' => GenieACS::getDeviceInfo($matchedDevice),
+ 'mac' => $mac,
+ 'externalIp' => GenieACS::getExternalIP($matchedDevice),
+ 'managementIp' => GenieACS::getManagementIP($matchedDevice)
+ ]);
+ } catch (Exception $e) {
+ $this->log->debug("GetDeviceByMac Error", ['error' => $e->getMessage()]);
+ self::sendError("Error fetching device: " . $e->getMessage());
+ }
+ }
+
+ protected function genieacsRefreshDeviceAction() {
+ try {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $deviceId = $input['deviceId'] ?? null;
+ $this->log->debug("genieacsRefreshDeviceAction", ['deviceId' => $deviceId]);
+
+ if (!$deviceId) self::sendError("Device ID is required");
+
+ $acs = $this->getGenieACS();
+
+ $resolvedId = $this->resolveDeviceId($deviceId, $acs);
+ if (!$resolvedId) self::sendError("Device not found in GenieACS");
+
+ $acs->getParameterValues($resolvedId, [
+ 'InternetGatewayDevice.DeviceInfo.HardwareVersion',
+ 'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
+ 'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress',
+ 'InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress',
+ 'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID',
+ 'InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase',
+ 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName',
+ 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress',
+ 'InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress'
+ ]);
+
+ $device = $acs->getDevice($resolvedId);
+
+ self::returnJson([
+ 'success' => true,
+ 'deviceInfo' => GenieACS::getDeviceInfo($device),
+ 'externalIp' => GenieACS::getExternalIP($device),
+ 'managementIp' => GenieACS::getManagementIP($device)
+ ]);
+ } catch (Exception $e) {
+ $this->log->debug("RefreshDevice Error", ['error' => $e->getMessage()]);
+ self::sendError("Error refreshing device: " . $e->getMessage());
+ }
+ }
+
protected function genieacsRebootDeviceAction() {
try {
$input = json_decode(file_get_contents('php://input'), true);
@@ -310,8 +438,11 @@ class RadiusController extends mfBaseController {
if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS();
- $creds = $acs->createRemoteUser($deviceId);
+ $resolvedId = $this->resolveDeviceId($deviceId, $acs);
+ if (!$resolvedId) self::sendError("Device not found in GenieACS");
+
+ $creds = $acs->createRemoteUser($resolvedId);
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz-eventlog";
@@ -362,8 +493,11 @@ class RadiusController extends mfBaseController {
if (!$deviceId) self::sendError("Device ID is required");
$acs = $this->getGenieACS();
- $creds = $acs->createRemoteUser($deviceId);
+ $resolvedId = $this->resolveDeviceId($deviceId, $acs);
+ if (!$resolvedId) self::sendError("Device not found in GenieACS");
+
+ $creds = $acs->createRemoteUser($resolvedId);
if (!$creds) self::sendError("Could not obtain credentials for FritzBox");
$url = "http://acs.xinon.at:5000/read-fritz";
@@ -523,47 +657,302 @@ class RadiusController extends mfBaseController {
private function getVendor($mac) {
-
+
$mac = strtoupper(str_replace([':', '-', '.'], '', $mac));
-
+
if (strlen($mac) < 6) return null;
-
-
-
+
+
+
$path = TEMP_DIR . '/mac-vendors.csv';
-
+
if (!file_exists($path)) return null;
-
-
-
+
+
+
// Format as XX:XX:XX
-
+
$prefix = substr($mac, 0, 2) . ':' . substr($mac, 2, 2) . ':' . substr($mac, 4, 2);
-
-
-
- // Use grep for speed if available, else fallback to basic search?
-
+
+
+
+ // Use grep for speed if available, else fallback to basic search?
+
// Assuming Linux env as per docker context.
-
+
$cmd = "grep -m 1 \"^" . $prefix . "\" " . escapeshellarg($path);
-
+
$output = shell_exec($cmd);
-
-
-
+
+
+
if ($output) {
-
+
$parts = str_getcsv($output);
-
+
if (isset($parts[1])) return $parts[1];
-
+
}
-
+
return null;
-
+
}
-
+
+ // ========== AVM Scanner Methods ==========
+
+ private static $avmScannerStateFile = null;
+
+ private static $avmPrefixes = [
+ '00:04:0E', '00:15:0C', '00:1A:4F', '00:1C:4A', '00:1F:3F', '00:24:FE',
+ '04:B4:FE', '08:96:D7', '08:B6:57', '0C:72:74',
+ '1C:ED:6F',
+ '24:65:11', '2C:3A:FD', '2C:91:AB',
+ '34:31:C4', '34:81:C4', '34:E1:A9', '38:10:D5', '3C:37:12', '3C:A6:2F',
+ '44:4E:6D', '48:5D:35',
+ '50:E6:36', '5C:49:79',
+ '60:B5:8D',
+ '74:42:7F', '7C:FF:4D',
+ '80:23:95',
+ '98:9B:CB', '98:A9:65', '9C:C7:A6',
+ 'B0:F2:08', 'B4:FC:7D', 'BC:05:43',
+ 'C0:25:06', 'C8:0E:14', 'CC:CE:1E',
+ 'D0:12:CB', 'D4:24:DD', 'DC:15:C8', 'DC:39:6F',
+ 'E0:08:55', 'E0:28:6D', 'E8:DF:70',
+ 'F0:B0:14'
+ ];
+
+ private function getAvmScannerStatePath(): string {
+ return BASEDIR . '/files/avm_scanner.json';
+ }
+
+ private function loadAvmScannerState(): array {
+ $path = $this->getAvmScannerStatePath();
+ if (file_exists($path)) {
+ $content = file_get_contents($path);
+ $state = json_decode($content, true);
+ if (is_array($state)) return $state;
+ }
+ return [
+ 'scanning' => false,
+ 'stopRequested' => false,
+ 'progress' => ['current' => 0, 'total' => 0],
+ 'currentDevice' => null,
+ 'startedAt' => null,
+ 'startedBy' => null,
+ 'lastUpdated' => date('c'),
+ 'devices' => []
+ ];
+ }
+
+ private function saveAvmScannerState(array $state): void {
+ $dir = dirname($this->getAvmScannerStatePath());
+ if (!is_dir($dir)) @mkdir($dir, 0777, true);
+ $state['lastUpdated'] = date('c');
+ file_put_contents($this->getAvmScannerStatePath(), json_encode($state, JSON_PRETTY_PRINT));
+ }
+
+ private function isAvmMac(string $mac): bool {
+ $mac = strtoupper(trim($mac));
+ $prefix = substr($mac, 0, 8);
+ return in_array($prefix, self::$avmPrefixes);
+ }
+
+ private function fetchRadiusUsersFromApi(): array {
+ $url = "http://radius.xinon.at/api.php";
+ $opts = [
+ "http" => [
+ "method" => "GET",
+ "header" => "Authorization: Basic " . base64_encode("admin:saveman"),
+ "timeout" => 120
+ ]
+ ];
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+ if ($response === false) return [];
+ $data = json_decode($response, true);
+ return is_array($data) ? $data : [];
+ }
+
+ private function fetchRadacctForUser(string $username): ?array {
+ $url = "http://radius.xinon.at/api.php?action2=fetchRadacct&username=" . urlencode($username);
+ $opts = [
+ "http" => [
+ "method" => "GET",
+ "header" => "Authorization: Basic " . base64_encode("admin:saveman"),
+ "timeout" => 10
+ ]
+ ];
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+ if ($response === false) return null;
+ $data = json_decode($response, true);
+ return is_array($data) ? $data : null;
+ }
+
+ protected function avmScannerGetStateAction() {
+ $state = $this->loadAvmScannerState();
+ header('Content-Type: application/json');
+ echo json_encode($state);
+ die();
+ }
+
+ protected function avmScannerGetUsersAction() {
+ $users = $this->fetchRadiusUsersFromApi();
+ $debug = $_GET['debug'] ?? false;
+
+ $macUsers = 0;
+ $avmMacUsers = 0;
+ $notInAcs = 0;
+ $macPrefixCounts = [];
+
+ $avmUsers = [];
+ foreach ($users as $user) {
+ $username = trim($user['username'] ?? '');
+ if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
+ $macUsers++;
+
+ $prefix = strtoupper(substr($username, 0, 8));
+ $macPrefixCounts[$prefix] = ($macPrefixCounts[$prefix] ?? 0) + 1;
+
+ if (!$this->isAvmMac($username)) continue;
+ $avmMacUsers++;
+
+ $info = $user['info'] ?? '';
+ if (stripos($info, 'ACS') !== false) continue;
+ $notInAcs++;
+
+ $user['username'] = $username;
+ $avmUsers[] = $user;
+ }
+
+ if ($debug) {
+ arsort($macPrefixCounts);
+ header('Content-Type: application/json');
+ echo json_encode([
+ 'totalFromApi' => count($users),
+ 'macAddressUsers' => $macUsers,
+ 'avmMacUsers' => $avmMacUsers,
+ 'notInAcs' => $notInAcs,
+ 'topMacPrefixes' => array_slice($macPrefixCounts, 0, 30, true),
+ 'avmPrefixes' => self::$avmPrefixes
+ ]);
+ die();
+ }
+
+ header('Content-Type: application/json');
+ echo json_encode(['users' => $avmUsers, 'count' => count($avmUsers)]);
+ die();
+ }
+
+ protected function avmScannerStartAction() {
+ $state = $this->loadAvmScannerState();
+ if ($state['scanning']) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'message' => 'Scan already running']);
+ die();
+ }
+
+ // Count how many users will be scanned (for immediate feedback)
+ $users = $this->fetchRadiusUsersFromApi();
+ $count = 0;
+ foreach ($users as $user) {
+ $username = trim($user['username'] ?? '');
+ if (!preg_match('/^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/i', $username)) continue;
+ if (!$this->isAvmMac($username)) continue;
+ $info = $user['info'] ?? '';
+ if (stripos($info, 'ACS') !== false) continue;
+ $count++;
+ }
+
+ // Spawn background script using nohup to ensure it runs independently
+ $scriptPath = BASEDIR . '/scripts/avm_scanner.php';
+ $logPath = BASEDIR . '/files/avm_scanner.log';
+ $cmd = "nohup php " . escapeshellarg($scriptPath) . " >> " . escapeshellarg($logPath) . " 2>&1 &";
+ shell_exec($cmd);
+
+ header('Content-Type: application/json');
+ echo json_encode(['success' => true, 'message' => 'Scan started', 'total' => $count]);
+ die();
+ }
+
+ protected function avmScannerStopAction() {
+ $state = $this->loadAvmScannerState();
+ $state['stopRequested'] = true;
+ $this->saveAvmScannerState($state);
+ header('Content-Type: application/json');
+ echo json_encode(['success' => true, 'message' => 'Stop requested']);
+ die();
+ }
+
+ protected function avmScannerToggleErledigtAction() {
+ $input = json_decode(file_get_contents('php://input'), true);
+ $mac = $input['mac'] ?? null;
+ if (!$mac) {
+ header('Content-Type: application/json');
+ http_response_code(400);
+ echo json_encode(['error' => 'MAC address required']);
+ die();
+ }
+
+ $state = $this->loadAvmScannerState();
+ foreach ($state['devices'] as &$device) {
+ if ($device['mac'] === $mac) {
+ $device['erledigt'] = !($device['erledigt'] ?? false);
+ break;
+ }
+ }
+ $this->saveAvmScannerState($state);
+ header('Content-Type: application/json');
+ echo json_encode(['success' => true]);
+ die();
+ }
+
+ private function detectFritzPort(string $ip): ?int {
+ $url = "https://acs.xinon.at/detect-port";
+ $data = json_encode(['fritz_ip' => $ip, 'timeout' => 3]);
+ $opts = [
+ "http" => [
+ "method" => "POST",
+ "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
+ "content" => $data,
+ "timeout" => 15
+ ],
+ "ssl" => ["verify_peer" => false, "verify_peer_name" => false]
+ ];
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+ if ($response) {
+ $json = json_decode($response, true);
+ if ($json && $json['success'] && isset($json['port'])) {
+ return (int)$json['port'];
+ }
+ }
+ return null;
+ }
+
+ private function detectFritzDevice(string $ip, int $port): ?array {
+ $url = "https://acs.xinon.at/detect-device";
+ $data = json_encode(['fritz_ip' => $ip, 'fritz_port' => (string)$port]);
+ $opts = [
+ "http" => [
+ "method" => "POST",
+ "header" => "Content-Type: application/json\r\nContent-Length: " . strlen($data) . "\r\n",
+ "content" => $data,
+ "timeout" => 15
+ ],
+ "ssl" => ["verify_peer" => false, "verify_peer_name" => false]
+ ];
+ $context = stream_context_create($opts);
+ $response = @file_get_contents($url, false, $context);
+ if ($response) {
+ $json = json_decode($response, true);
+ if ($json && $json['success'] && isset($json['device'])) {
+ return $json['device'];
+ }
+ }
+ return null;
+ }
+
}
\ No newline at end of file
diff --git a/application/RimoWorkorder/RimoWorkorder.php b/application/RimoWorkorder/RimoWorkorder.php
index 31dcb8994..0b6bccc89 100644
--- a/application/RimoWorkorder/RimoWorkorder.php
+++ b/application/RimoWorkorder/RimoWorkorder.php
@@ -1,5 +1,7 @@
id || !$this->adb_wohneinheit_id) return ['success' => false, 'message' => 'Missing ID'];
+ $preorder = PreorderModel::getFirstActive(["adb_wohneinheit_id" => $this->adb_wohneinheit_id]);
+ if (!$preorder?->id) return ['success' => false, 'message' => 'No active Preorder'];
+ $workorder = WorkorderModel::getFirst(['preorderId' => $preorder->id]);
+ if (!$workorder || !($pdf = $this->getAha())) return ['success' => false, 'message' => 'No Workorder or PDF'];
+
+ try {
+ $dropkabel = $this->parseDropkabelFromPdf($pdf);
+ $map = $this->extractMapFromPdf($pdf);
+ $meta = json_decode($workorder->metadata ?: '{}', true) ?: [];
+ $mapFileId = null;
+
+ if ($map) {
+ if ($oldId = ($meta['dropcable']['map_file_id'] ?? null)) {
+ $old = new File($oldId); if ($old->id) try { $old->delete(); } catch (Exception $e) {}
+ }
+ $fn = 'aha_lageplan_' . $this->id . '_' . time() . '.png';
+ $file = FileModel::create(['name' => 'AHA Lageplan ' . $this->rimo_name, 'filename' => $fn,
+ 'store_filename' => $fn, 'orig_filename' => 'AHA_Lageplan_' . $this->rimo_name . '.png',
+ 'mimetype' => 'image/png', 'subfolder' => 'aha_maps', 'create_by' => 1]);
+ if ($file->save()) {
+ $dir = MFUPLOAD_FILE_SAVE_PATH . '/aha_maps';
+ if (!is_dir($dir)) mkdir($dir, 0755, true);
+ if (file_put_contents("$dir/$fn", $map)) $mapFileId = $file->id;
+ }
+ }
+ $meta['dropcable'] = ['rimo_workorder_id' => $this->id, 'rimo_name' => $this->rimo_name,
+ 'parsed_at' => time(), 'entries' => $dropkabel, 'map_file_id' => $mapFileId];
+ $workorder->metadata = json_encode($meta);
+ WorkorderModel::update((array)$workorder);
+ return ['success' => true, 'dropkabel_count' => count($dropkabel), 'has_map' => (bool)$mapFileId];
+ } catch (Exception $e) {
+ $this->log->error(__METHOD__ . ": " . $e->getMessage());
+ return ['success' => false, 'message' => $e->getMessage()];
+ }
+ }
+
+ public static function autoParseForWorkorder(int $workorderId, bool $force = false): void {
+ $wo = WorkorderModel::get($workorderId);
+ if (!$wo) return;
+ $meta = json_decode($wo->metadata ?? '{}', true);
+ if (($force || empty($meta['dropcable']['parsed_at'])) && $wo->preorderId) {
+ $pre = new Preorder($wo->preorderId);
+ $rimos = $pre->adb_wohneinheit_id ? RimoWorkorderModel::search(['adb_wohneinheit_id' => $pre->adb_wohneinheit_id]) : [];
+ if (!empty($rimos[0])) (new self($rimos[0]->id))->parseAha();
+ }
+ }
+
+ private function parseDropkabelFromPdf(string $pdf): array {
+ $result = [];
+ $text = (new Parser())->parseContent($pdf)->getPages()[0]?->getText() ?? '';
+
+ if (!preg_match('/Dropkabel:\s*\n(.+?)(?:Lage:|$)/s', $text, $m)) {
+ // Try alternative pattern without strict newline requirement
+ if (!preg_match('/Dropkabel[:\s]*(.+?)(?:Lage|Anschluss|$)/si', $text, $m)) {
+ return $result;
+ }
+ }
+
+ $started = false;
+ foreach (explode("\n", $m[1]) as $line) {
+ $line = trim($line);
+ if (!$line) continue;
+
+ // Check for header line (ID and Type columns)
+ if (stripos($line, 'ID') !== false && (stripos($line, 'Type') !== false || stripos($line, 'Typ') !== false)) {
+ $started = true;
+ continue;
+ }
+
+ // Flexible cable ID pattern - matches F26-K009, F-ABC123-K01, F-XYZ(1)-K02, etc.
+ if ($started && preg_match('#^([A-Z][A-Z0-9()/_-]*-K\d+)\s+(.+)$#i', $line, $p)) {
+ $rest = $p[2]; $status = '';
+ foreach (['Planfreigabe', 'Plan released', 'Grobplanung', 'Executed', 'Ausgeführt', 'Detailed planning', 'Detailplanung'] as $s)
+ if (preg_match('/\b' . preg_quote($s, '/') . '\s*$/i', $rest)) {
+ $status = $s; $rest = trim(preg_replace('/\b' . preg_quote($s, '/') . '\s*$/i', '', $rest)); break;
+ }
+ $lp = $li = '';
+ if (preg_match_all('/(\d+)\s*m\b/', $rest, $lens, PREG_SET_ORDER)) {
+ $lp = ($lens[0][1] ?? '') . ' m'; $li = ($lens[1][1] ?? '') . ' m';
+ $rest = preg_replace('/\d+\s*m\b/', '', $rest);
+ }
+ $result[] = ['cable_id' => trim($p[1]), 'type' => trim(preg_replace('/\s+/', ' ', $rest)),
+ 'laenge_plan' => $lp, 'laenge_ist' => $li, 'status' => $status];
+ }
+ }
+
+ return $result;
+ }
+
+ private function extractMapFromPdf(string $pdf): ?string {
+ $tmp = tempnam(sys_get_temp_dir(), 'aha_'); file_put_contents($tmp, $pdf);
+ $out = tempnam(sys_get_temp_dir(), 'aha_img_'); unlink($out);
+ exec(sprintf('pdftoppm -png -f 1 -l 1 -r 150 %s %s 2>&1', escapeshellarg($tmp), escapeshellarg($out)), $_, $ret);
+ @unlink($tmp);
+ $outFile = file_exists("$out-1.png") ? "$out-1.png" : "$out.png";
+ if ($ret !== 0 || !file_exists($outFile)) return null;
+ $img = @imagecreatefromstring(file_get_contents($outFile)); @unlink($outFile);
+ if (!$img) return null;
+ $h = imagesy($img); $cropY = (int)($h * 0.42); $cropH = (int)($h * 0.84) - $cropY;
+ $cropped = imagecrop($img, ['x' => 60, 'y' => $cropY, 'width' => imagesx($img) - 90, 'height' => $cropH]);
+ imagedestroy($img); if (!$cropped) return null;
+ ob_start(); imagepng($cropped, null, 6); $content = ob_get_clean(); imagedestroy($cropped);
+ return $content;
+ }
public function getProperty($name) {
if($this->$name == null) {
diff --git a/application/RimoWorkorder/RimoWorkorderController.php b/application/RimoWorkorder/RimoWorkorderController.php
index 1038575c0..5fd097a28 100644
--- a/application/RimoWorkorder/RimoWorkorderController.php
+++ b/application/RimoWorkorder/RimoWorkorderController.php
@@ -13,6 +13,7 @@ class RimoWorkorderController extends mfBaseController {
protected function downloadAhaAction() {
$workorder_id = $this->request->id;
+ $inline = !empty($this->request->inline);
if(!$workorder_id || $workorder_id < 1) {
header("HTTP/1.1 400 Bad Request");
@@ -34,10 +35,33 @@ class RimoWorkorderController extends mfBaseController {
exit;
}
- header("Content-type: text/pdf");
- header('Content-disposition: attachment; filename="'.$workorder->rimo_name.'_AHA.pdf"');
+ $filename = $workorder->rimo_name.'_AHA.pdf';
+ $disposition = $inline ? 'inline' : 'attachment';
+
+ header("Content-type: application/pdf");
+ header('Content-disposition: '.$disposition.'; filename="'.$filename.'"');
echo $return;
exit;
}
+ protected function parseAhaAction() {
+ header('Content-Type: application/json');
+ $post = json_decode(file_get_contents('php://input'), true);
+ $id = $post['id'] ?? $this->request->id ?? null;
+
+ if (!$id || $id < 1) {
+ echo json_encode(['success' => false, 'message' => 'Invalid workorder id.']);
+ exit;
+ }
+
+ $wo = new RimoWorkorder($id);
+ if (!$wo->id) {
+ echo json_encode(['success' => false, 'message' => 'RimoWorkorder nicht gefunden.']);
+ exit;
+ }
+
+ echo json_encode($wo->parseAha());
+ exit;
+ }
+
}
\ No newline at end of file
diff --git a/application/User/UserController.php b/application/User/UserController.php
index 06c1ba42e..eb9f27e23 100644
--- a/application/User/UserController.php
+++ b/application/User/UserController.php
@@ -12,6 +12,9 @@ class UserController extends mfBaseController
{
private $me;
+ // User IDs allowed to manage (add/edit/delete) users
+ private const ALLOWED_USER_MANAGER_IDS = [2, 5, 9, 6, 89, 145, 24];
+
protected function init($request = null)
{
$this->needlogin = true;
@@ -24,6 +27,11 @@ class UserController extends mfBaseController
if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true);
}
+ private function canManageUsers(): bool
+ {
+ return in_array($this->me->id, self::ALLOWED_USER_MANAGER_IDS);
+ }
+
protected function indexAction($request)
{
if (!$this->isAdmin()) {
@@ -32,6 +40,7 @@ class UserController extends mfBaseController
Helper::renderVue($this, "User", "Benutzer", [
"IS_ADMIN" => $this->me->isAdmin(),
+ "CAN_MANAGE_USERS" => $this->canManageUsers(),
"USERS" => array_map(fn($user) => [
"username" => $user->username,
"name" => $user->name,
@@ -53,6 +62,7 @@ class UserController extends mfBaseController
protected function formAction() {
if (!$this->isAdmin()) $this->redirect("Dashboard");
+ if (!$this->canManageUsers()) $this->redirect("User");
$id = $this->request->id;
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
@@ -178,6 +188,7 @@ class UserController extends mfBaseController
protected function generateApikeyAction($request) {
if (!$this->isAdmin()) $this->redirect("Dashboard");
+ if (!$this->canManageUsers()) $this->redirect("User");
$id = $request['id'];
if (!is_numeric($id) || $id < 1) {
@@ -207,6 +218,11 @@ class UserController extends mfBaseController
unset($r->address_id);
}
+ // Only allowed users can create/edit other users
+ if ($this->isAdmin() && !$this->canManageUsers()) {
+ self::redirect('User');
+ }
+
if (!$id && !$r->username) self::redirect('User');
$user = new User($id);
@@ -569,7 +585,7 @@ class UserController extends mfBaseController
}
protected function impersonateAction() {
- if(!$this->me->isAdmin() || $this->me->address_id != 1) {
+ if(!$this->me->isAdmin() || $this->me->address_id != 1 || !$this->canManageUsers()) {
header("HTTP/1.1 403 Forbidden");
exit;
}
@@ -590,6 +606,10 @@ class UserController extends mfBaseController
protected function sendLoginEmailAction()
{
+ if (!$this->canManageUsers()) {
+ self::sendError("Keine Berechtigung.");
+ }
+
$id = $this->request->id;
if (!$id || !is_numeric($id)) {
self::sendError("Benutzer-ID fehlt oder ist ungültig.");
diff --git a/application/Voicenumber/VoicenumberController.php b/application/Voicenumber/VoicenumberController.php
index 1e192288c..22ec7d427 100644
--- a/application/Voicenumber/VoicenumberController.php
+++ b/application/Voicenumber/VoicenumberController.php
@@ -114,8 +114,11 @@ class VoicenumberController extends mfBaseController {
$number_data['port_out_date'] = self::dateToTimestamp($r->port_out_date);
}
- if($r->disabled === "1") {
- $number_data['disabled'] = 1;
+ if($r->disabled == "1") {
+ if(!$number->disabled) {
+ $number_data['disabled'] = date('U');
+ $number_data['disabled_by'] = $this->me->id;
+ }
switch($r->disabled_reason) {
case "ported_out":
$number_data['disabled_reason'] = "ported_out";
@@ -123,6 +126,9 @@ class VoicenumberController extends mfBaseController {
case "ported_back":
$number_data['disabled_reason'] = "ported_back";
break;
+ case "contract_cancelled":
+ $number_data['disabled_reason'] = "contract_cancelled";
+ break;
case "legacy":
$number_data['disabled_reason'] = "legacy";
break;
diff --git a/application/Voicenumber/VoicenumberModel.php b/application/Voicenumber/VoicenumberModel.php
index 28da70fb8..33fc4d1f0 100644
--- a/application/Voicenumber/VoicenumberModel.php
+++ b/application/Voicenumber/VoicenumberModel.php
@@ -17,6 +17,7 @@ class VoicenumberModel {
public $ported_out;
public $disabled;
public $disabled_reason;
+ public $disabled_by;
public $enable_on_date;
public $comment;
diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php
index c80214bfd..d7edbaded 100644
--- a/application/WarehouseArticle/WarehouseArticleController.php
+++ b/application/WarehouseArticle/WarehouseArticleController.php
@@ -13,7 +13,7 @@ class WarehouseArticleController extends TTCrud {
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
- ['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
+ ['key' => 'vatgroup_id', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 2, 'text' => 'Dienstleistungen'], ['value' => 3, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true,'modal' => ['type' => 'number'], 'table' => false],
@@ -111,7 +111,7 @@ class WarehouseArticleController extends TTCrud {
if ($categoryId) {
$category = WarehouseCategory::get($categoryId);
if ($category && $category->articleNumberPrefix) {
- $expectedPrefix = $category->articleNumberPrefix;
+ $expectedPrefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
if ($articlePrefix !== $expectedPrefix) {
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
@@ -178,7 +178,7 @@ class WarehouseArticleController extends TTCrud {
if (!$category) self::sendError("Kategorie nicht gefunden");
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
- $prefix = $category->articleNumberPrefix;
+ $prefix = str_pad($category->articleNumberPrefix, 4, '0', STR_PAD_LEFT);
$db = FronkDB::singleton();
// Get all existing article numbers with this prefix, sorted
@@ -253,7 +253,7 @@ class WarehouseArticleController extends TTCrud {
];
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
- $wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
+ $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
@@ -262,4 +262,30 @@ class WarehouseArticleController extends TTCrud {
readfile($filename);
die();
}
+
+ protected function printLabelsByCategoryAction() {
+ $categoryId = intval($this->request->categoryId);
+ if (!$categoryId) {
+ self::sendError("Kategorie nicht angegeben", 400);
+ }
+
+ $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
+ if (empty($articles)) {
+ self::sendError("Keine Artikel in dieser Kategorie gefunden", 404);
+ }
+
+ $pdf_vars = ['articles' => $articles];
+ $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
+ $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
+
+ $filename = $pdf->render($wkhtmltopdfArgs);
+
+ $category = WarehouseCategory::get($categoryId);
+ $categoryName = $category ? $category->name : 'category-' . $categoryId;
+
+ header('Content-Type: application/pdf');
+ header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
+ readfile($filename);
+ die();
+ }
}
diff --git a/application/WarehouseArticle/WarehouseArticleModel.php b/application/WarehouseArticle/WarehouseArticleModel.php
index 245e3bb64..a142c81fd 100644
--- a/application/WarehouseArticle/WarehouseArticleModel.php
+++ b/application/WarehouseArticle/WarehouseArticleModel.php
@@ -17,7 +17,7 @@ class WarehouseArticleModel extends TTCrudBaseModel {
public ?int $isEndOfLife;
public string $unit;
public ?int $isSerialDocumentation;
- public int $revenueAccount;
+ public int $vatgroup_id;
public int $create;
public int $createBy;
}
\ No newline at end of file
diff --git a/application/WarehouseCategory/WarehouseCategory.php b/application/WarehouseCategory/WarehouseCategory.php
index 129b19f18..46aeec39a 100644
--- a/application/WarehouseCategory/WarehouseCategory.php
+++ b/application/WarehouseCategory/WarehouseCategory.php
@@ -5,7 +5,7 @@ class WarehouseCategory extends TTCrudBaseModel {
public string $description;
public ?string $articleNumberPrefix;
public int $create;
- public int $create_by;
+ public ?int $create_by;
public ?int $edit;
public ?int $edit_by;
-}
\ No newline at end of file
+}
diff --git a/application/WarehouseCategory/WarehouseCategoryController.php b/application/WarehouseCategory/WarehouseCategoryController.php
index 26d16ab83..40fe2a231 100644
--- a/application/WarehouseCategory/WarehouseCategoryController.php
+++ b/application/WarehouseCategory/WarehouseCategoryController.php
@@ -16,7 +16,39 @@ class WarehouseCategoryController extends TTCrud {
];
// @formatter:on
- protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
+ protected array $additionalActions = [
+ ['key' => 'printLabels', 'title' => 'Labels drucken', 'class' => 'fas fa-print text-primary'],
+ ['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']
+ ];
+
+ protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
+
+ public function printLabelsAction() {
+ $categoryId = intval($this->request->id);
+ $articles = WarehouseArticleModel::getAll(['category_id' => $categoryId], 10000, 0, ['key' => 'articleNumber', 'order' => 'ASC']);
+
+ if (empty($articles)) {
+ echo "Keine Artikel in dieser Kategorie.";
+ die();
+ }
+
+ $pdf_vars = [
+ 'articles' => $articles
+ ];
+
+ $pdf = new PdfForm("WarehouseArticle/LABEL_BULK", $pdf_vars);
+ $wkhtmltopdfArgs = "--page-height 25mm --page-width 63mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
+
+ $filename = $pdf->render($wkhtmltopdfArgs);
+
+ $category = WarehouseCategory::get($categoryId);
+ $categoryName = $category ? $category->name : 'category-' . $categoryId;
+
+ header('Content-Type: application/pdf');
+ header('Content-Disposition: inline; filename="labels-' . str_replace(' ', '_', $categoryName) . '.pdf"');
+ readfile($filename);
+ die();
+ }
protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
diff --git a/application/WarehouseMovement/WarehouseMovementController.php b/application/WarehouseMovement/WarehouseMovementController.php
new file mode 100644
index 000000000..f8e6711f2
--- /dev/null
+++ b/application/WarehouseMovement/WarehouseMovementController.php
@@ -0,0 +1,245 @@
+ 'movementNumber', 'text' => 'Bewegungs-Nr.', 'required' => false,
+ 'modal' => false,
+ 'table' => ['priority' => 10]],
+ ['key' => 'movementType', 'text' => 'Typ', 'required' => true,
+ 'modal' => ['type' => 'select', 'items' => []],
+ 'table' => ['priority' => 9, 'filter' => 'iconSelect', 'filterOptions' => [
+ ['value' => 'IN', 'text' => 'Einbuchung', 'icon' => 'fas fa-plus-circle text-success'],
+ ['value' => 'OUT', 'text' => 'Ausbuchung', 'icon' => 'fas fa-minus-circle text-danger'],
+ ['value' => 'ADJUSTMENT', 'text' => 'Korrektur', 'icon' => 'fas fa-edit text-warning'],
+ ]]],
+ ['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
+ 'modal' => ['type' => 'articleSelect'],
+ 'table' => ['priority' => 8, 'sortable' => false, 'filter' => 'text']],
+ ['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
+ 'modal' => ['type' => 'select', 'items' => []],
+ 'table' => ['priority' => 7, 'filter' => 'select']],
+ ['key' => 'quantity', 'text' => 'Menge', 'required' => true,
+ 'modal' => ['type' => 'number', 'step' => '0.01', 'min' => '0.01'],
+ 'table' => ['priority' => 6, 'filter' => false]],
+ ['key' => 'quantityBefore', 'text' => 'Bestand vorher', 'required' => false,
+ 'modal' => false,
+ 'table' => ['priority' => 5, 'filter' => false]],
+ ['key' => 'quantityAfter', 'text' => 'Bestand nachher', 'required' => false,
+ 'modal' => false,
+ 'table' => ['priority' => 4, 'filter' => false]],
+ ['key' => 'reasonCategory', 'text' => 'Grund', 'required' => true,
+ 'modal' => ['type' => 'select', 'items' => [], 'dependsOn' => 'movementType'],
+ 'table' => ['priority' => 3, 'filter' => false]],
+ ['key' => 'note', 'text' => 'Notiz', 'required' => false,
+ 'modal' => ['type' => 'textarea'],
+ 'table' => ['priority' => 2, 'filter' => false]],
+ ['key' => 'create', 'text' => 'Erstellt', 'required' => false,
+ 'modal' => false,
+ 'table' => ['priority' => 1, 'filter' => 'dateRange']],
+ ];
+
+ protected array $additionalActions = [];
+
+ protected array $permissionCheck = ['WarehouseUser'];
+
+ protected array $infoMessages = [
+ 'create' => 'Lagerbewegung wurde erstellt',
+ 'update' => 'Lagerbewegung wurde aktualisiert',
+ 'delete' => 'Lagerbewegung wurde gelöscht',
+ 'noChanges' => 'Keine Änderungen',
+ ];
+
+ public function prepareCrudConfig() {
+ // Populate movement type dropdown
+ $movementTypes = [
+ ['value' => 'IN', 'text' => 'Einbuchung'],
+ ['value' => 'OUT', 'text' => 'Ausbuchung'],
+ ['value' => 'ADJUSTMENT', 'text' => 'Korrektur'],
+ ];
+
+ // Populate locations dropdown (Office + Außenlager only)
+ $allLocations = WarehouseLocationModel::getAll();
+ $locations = [];
+ foreach ($allLocations as $location) {
+ $title = strtolower($location->title);
+ if ($title === 'k1 fladnitz 150' || $title === 'aussenlager-extern') {
+ $locations[] = ['value' => $location->id, 'text' => $location->title];
+ }
+ }
+
+ // Get all reason categories for initial load
+ $allReasons = WarehouseMovementModel::getReasonCategories();
+ $reasonItems = [];
+ foreach ($allReasons as $type => $categories) {
+ foreach ($categories as $key => $label) {
+ $reasonItems[] = ['value' => $key, 'text' => $label, 'group' => $type];
+ }
+ }
+
+ foreach ($this->columns as &$col) {
+ if ($col['key'] === 'movementType') {
+ $col['modal']['items'] = $movementTypes;
+ }
+ if ($col['key'] === 'warehouseLocationId') {
+ $col['modal']['items'] = $locations;
+ $col['table']['filterOptions'] = $locations;
+ }
+ if ($col['key'] === 'reasonCategory') {
+ $col['modal']['items'] = $reasonItems;
+ }
+ }
+
+ $this->additionalJSVariables['REASON_CATEGORIES'] = $allReasons;
+ }
+
+ protected function beforeCreate(): bool {
+ // Validate required fields
+ $movementType = $this->postData['movementType'] ?? '';
+ $articleId = intval($this->postData['articleId'] ?? 0);
+ $locationId = intval($this->postData['warehouseLocationId'] ?? 0);
+ $quantity = floatval($this->postData['quantity'] ?? 0);
+
+ if (!in_array($movementType, ['IN', 'OUT', 'ADJUSTMENT'])) {
+ $this->returnJson(['success' => false, 'message' => 'Ungültiger Bewegungstyp']);
+ return false;
+ }
+
+ if ($articleId <= 0) {
+ $this->returnJson(['success' => false, 'message' => 'Kein Artikel ausgewählt']);
+ return false;
+ }
+
+ if ($locationId <= 0) {
+ $this->returnJson(['success' => false, 'message' => 'Kein Lagerort ausgewählt']);
+ return false;
+ }
+
+ if ($quantity <= 0) {
+ $this->returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
+ return false;
+ }
+
+ // Find or create WarehouseItem for this article at this location
+ $db = FronkDB::singleton();
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+
+ $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
+ $currentQty = $warehouseItem ? floatval($warehouseItem->quantity) : 0;
+
+ // Calculate new quantity based on movement type
+ // Note: Negative stock is allowed (items can be taken out even if stock is 0)
+ switch ($movementType) {
+ case 'IN':
+ $newQty = $currentQty + $quantity;
+ break;
+ case 'OUT':
+ $newQty = $currentQty - $quantity;
+ // Negative stock is allowed - no validation needed
+ break;
+ case 'ADJUSTMENT':
+ // For adjustment, quantity is the new absolute value
+ $newQty = $quantity;
+ break;
+ default:
+ $newQty = $currentQty;
+ }
+
+ // Store before/after quantities
+ $this->postData['quantityBefore'] = $currentQty;
+ $this->postData['quantityAfter'] = $newQty;
+ $this->postData['userId'] = $this->user->id;
+
+ // Update or create WarehouseItem
+ if ($warehouseItem) {
+ $db->query("UPDATE WarehouseItem SET quantity = {$newQty} WHERE id = {$warehouseItem->id}");
+ $this->postData['warehouseItemId'] = $warehouseItem->id;
+ } else {
+ $db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, createBy, `create`)
+ VALUES ({$articleId}, {$locationId}, {$newQty}, {$this->user->id}, " . time() . ")");
+ $this->postData['warehouseItemId'] = $db->insert_id();
+ }
+
+ return true;
+ }
+
+ protected function afterCreate($postData) {
+ // Generate movement number
+ $movement = WarehouseMovementModel::get($postData['id']);
+ if ($movement) {
+ $movementNumber = WarehouseMovementModel::generateMovementNumber();
+ $db = FronkDB::singleton();
+ $db->query("UPDATE WarehouseMovement SET movementNumber = '{$movementNumber}' WHERE id = {$movement->id}");
+ }
+ }
+
+ protected function customRowsHandler($rows) {
+ return array_map(fn($row) => $this->formatRow((array)$row), $rows);
+ }
+
+ protected function formatRow($row) {
+ $rawType = $row['movementType'];
+
+ if (!empty($row['articleId'])) {
+ $article = WarehouseArticleModel::get($row['articleId']);
+ if ($article) {
+ $row['articleId'] = "{$article->articleNumber} {$article->title}";
+ }
+ }
+
+ $row['quantityBefore'] = $row['quantityBefore'] !== null ? number_format((float)$row['quantityBefore'], 2, ',', '.') : '-';
+ $row['quantityAfter'] = $row['quantityAfter'] !== null ? number_format((float)$row['quantityAfter'], 2, ',', '.') : '-';
+ $row['quantity'] = number_format((float)$row['quantity'], 2, ',', '.');
+
+ $allCategories = WarehouseMovementModel::getReasonCategories();
+ $row['reasonCategory'] = $allCategories[$rawType][$row['reasonCategory']] ?? $row['reasonCategory'];
+
+ return $row;
+ }
+
+ /**
+ * Get reason categories for a specific movement type
+ */
+ protected function getReasonCategoriesAction() {
+ $type = $this->request->type ?? null;
+ $categories = WarehouseMovementModel::getReasonCategories($type);
+
+ if ($type && is_array($categories)) {
+ $items = [];
+ foreach ($categories as $key => $label) {
+ $items[] = ['value' => $key, 'text' => $label];
+ }
+ self::returnJson(['success' => true, 'categories' => $items]);
+ } else {
+ self::returnJson(['success' => true, 'categories' => $categories]);
+ }
+ }
+
+ /**
+ * Get current stock for an article at a location
+ */
+ protected function getCurrentStockAction() {
+ $articleId = intval($this->request->articleId ?? 0);
+ $locationId = intval($this->request->locationId ?? 0);
+
+ if (!$articleId || !$locationId) {
+ self::returnJson(['success' => false, 'currentStock' => 0]);
+ return;
+ }
+
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+
+ $currentStock = count($existingItems) > 0 ? floatval($existingItems[0]->quantity) : 0;
+
+ self::returnJson(['success' => true, 'currentStock' => $currentStock]);
+ }
+}
diff --git a/application/WarehouseMovement/WarehouseMovementModel.php b/application/WarehouseMovement/WarehouseMovementModel.php
new file mode 100644
index 000000000..fa069a20e
--- /dev/null
+++ b/application/WarehouseMovement/WarehouseMovementModel.php
@@ -0,0 +1,140 @@
+query("SELECT movementNumber FROM WarehouseMovement
+ WHERE movementNumber LIKE '{$prefix}%'
+ ORDER BY movementNumber DESC LIMIT 1");
+
+ if ($row = $result->fetch_assoc()) {
+ $lastNumber = intval(substr($row['movementNumber'], -6));
+ $nextNumber = $lastNumber + 1;
+ } else {
+ $nextNumber = 1;
+ }
+
+ return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
+ }
+
+ /**
+ * Get reason categories for a movement type
+ */
+ public static function getReasonCategories(?string $type = null): array {
+ $categories = [
+ 'IN' => [
+ 'Warenlieferung' => 'Warenlieferung',
+ 'Rueckgabe' => 'Rückgabe',
+ 'Gefunden' => 'Gefunden/Inventurdifferenz',
+ 'UmlagerungEingang' => 'Umlagerung (Eingang)',
+ 'Erstbestand' => 'Erstbestand',
+ 'Sonstiges' => 'Sonstiges'
+ ],
+ 'OUT' => [
+ 'Verbrauch' => 'Verbrauch',
+ 'Beschaedigung' => 'Beschädigung/Defekt',
+ 'Verlust' => 'Verlust/Schwund',
+ 'UmlagerungAusgang' => 'Umlagerung (Ausgang)',
+ 'Entsorgung' => 'Entsorgung',
+ 'Sonstiges' => 'Sonstiges'
+ ],
+ 'ADJUSTMENT' => [
+ 'Inventurkorrektur' => 'Inventurkorrektur',
+ 'Buchungsfehler' => 'Buchungsfehler',
+ 'Systemkorrektur' => 'Systemkorrektur',
+ 'SonstigeKorrektur' => 'Sonstige Korrektur'
+ ]
+ ];
+
+ if ($type && isset($categories[$type])) {
+ return $categories[$type];
+ }
+
+ return $categories;
+ }
+
+ /**
+ * Get movement type labels
+ */
+ public static function getMovementTypes(): array {
+ return [
+ 'IN' => 'Einbuchung',
+ 'OUT' => 'Ausbuchung',
+ 'ADJUSTMENT' => 'Korrektur'
+ ];
+ }
+
+ public function getArticle(): ?WarehouseArticleModel {
+ return WarehouseArticleModel::get($this->articleId);
+ }
+
+ /**
+ * Get location object
+ */
+ public function getLocation(): ?WarehouseLocationModel {
+ return WarehouseLocationModel::get($this->warehouseLocationId);
+ }
+
+ /**
+ * Get user who made the movement
+ */
+ public function getUser(): ?UserModel {
+ return UserModel::get($this->userId);
+ }
+
+ /**
+ * Get warehouse item if linked
+ */
+ public function getWarehouseItem(): ?WarehouseItemModel {
+ if (!$this->warehouseItemId) return null;
+ return WarehouseItemModel::get($this->warehouseItemId);
+ }
+
+ /**
+ * Get linked order if this movement was created from an order delivery
+ */
+ public function getLinkedOrder(): ?WarehouseOrderModel {
+ if (!$this->linkedOrderId) return null;
+ return WarehouseOrderModel::get($this->linkedOrderId);
+ }
+
+ /**
+ * Get formatted movement type label
+ */
+ public function getMovementTypeLabel(): string {
+ $types = self::getMovementTypes();
+ return $types[$this->movementType] ?? $this->movementType;
+ }
+
+ /**
+ * Get formatted reason category label
+ */
+ public function getReasonCategoryLabel(): string {
+ $allCategories = self::getReasonCategories();
+ foreach ($allCategories as $typeCategories) {
+ if (isset($typeCategories[$this->reasonCategory])) {
+ return $typeCategories[$this->reasonCategory];
+ }
+ }
+ return $this->reasonCategory;
+ }
+}
diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php
index 8658804f4..771e3ae58 100644
--- a/application/WarehouseOffer/WarehouseOfferController.php
+++ b/application/WarehouseOffer/WarehouseOfferController.php
@@ -56,7 +56,7 @@ class WarehouseOfferController extends TTCrud
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
- $this->postData['validity'] = 14;
+ $this->postData['validity'] = 31;
$this->postData['alternativePositions'] = json_encode([]);
return true;
}
@@ -366,10 +366,13 @@ class WarehouseOfferController extends TTCrud
$version = $this->request->version ?? null;
$offerData = null;
+ $versionDate = null; // Date when this version was created (for validity calculation)
+
if ($version) {
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
if ($historyEntry && !empty($historyEntry->data)) {
$offerData = json_decode($historyEntry->data);
+ $versionDate = $historyEntry->create; // Use version creation date
}
}
@@ -377,6 +380,10 @@ class WarehouseOfferController extends TTCrud
$offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
$offerData = $offer;
+
+ // Get latest history entry for current version's date
+ $latestHistory = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $offer->version);
+ $versionDate = $latestHistory ? $latestHistory->create : $offer->create;
}
@@ -432,11 +439,12 @@ class WarehouseOfferController extends TTCrud
"alternativeTotal" => $alternativeTotal,
"offerNumber" => $offerData->offerNumber,
"offerDate" => $offerData->create,
+ "versionDate" => $versionDate ?? $offerData->create, // Date for validity calculation
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
- "validity" => $offerData->validity ?? 14,
+ "validity" => $offerData->validity ?? 31,
"closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,
diff --git a/application/WarehouseOrder/WarehouseOrderController.php b/application/WarehouseOrder/WarehouseOrderController.php
index 0e4d9d939..dc486fcfe 100644
--- a/application/WarehouseOrder/WarehouseOrderController.php
+++ b/application/WarehouseOrder/WarehouseOrderController.php
@@ -406,9 +406,72 @@ $appendToBody
];
try {
+ $createdMovementIds = [];
+
+ // Create warehouse movements for delivery statuses
+ if (in_array($postData['status'], ['partiallyDelivered', 'fullyDelivered'])
+ && isset($postData['deliveryData']) && is_array($postData['deliveryData'])) {
+
+ // Get location ID from request or use default (K1 Fladnitz 150)
+ $locationId = intval($postData['locationId'] ?? 0);
+ if ($locationId <= 0) {
+ // Default to K1 Fladnitz 150
+ $allLocations = WarehouseLocationModel::getAll();
+ $defaultLocation = null;
+ foreach ($allLocations as $loc) {
+ if ($loc->title === 'K1 Fladnitz 150') {
+ $defaultLocation = $loc;
+ break;
+ }
+ }
+ $locationId = $defaultLocation ? $defaultLocation->id : 1;
+ }
+
+ // Prepare delivery data with articleId from order positions
+ $positions = json_decode($order->positions, true) ?: [];
+ $deliveryDataWithArticleIds = [];
+
+ foreach ($postData['deliveryData'] as $index => $delivery) {
+ if (isset($positions[$index])) {
+ $delivery['articleId'] = $positions[$index]['article'];
+ }
+ $deliveryDataWithArticleIds[] = $delivery;
+ }
+
+ $createdMovementIds = $this->createMovementsForDelivery(
+ intval($postData['orderId']),
+ $deliveryDataWithArticleIds,
+ $locationId
+ );
+
+ if (!empty($createdMovementIds)) {
+ // Update order with linked movement IDs
+ $existingMovementIds = $order->linkedMovementIds
+ ? json_decode($order->linkedMovementIds, true) : [];
+ $allMovementIds = array_merge($existingMovementIds, $createdMovementIds);
+ $orderAsArray['linkedMovementIds'] = json_encode($allMovementIds);
+
+ // Add movement info to log message
+ $fullLogMessage .= ($fullLogMessage ? "\n\n" : "") .
+ count($createdMovementIds) . " Lagerbewegung(en) erstellt.";
+ $log['message'] = trim($fullLogMessage);
+ }
+ }
+
+ // Store delivery note file IDs
+ if (!empty($postData['deliveryNoteFileIds'])) {
+ $existingFileIds = $order->deliveryNoteFileIds
+ ? json_decode($order->deliveryNoteFileIds, true) : [];
+ $allFileIds = array_merge($existingFileIds, $postData['deliveryNoteFileIds']);
+ $orderAsArray['deliveryNoteFileIds'] = json_encode($allFileIds);
+ }
+
if ($postData['status'] !== 'noChanges') {
$orderAsArray['status'] = $postData['status'];
WarehouseOrderModel::update($orderAsArray);
+ } elseif (!empty($orderAsArray['linkedMovementIds']) || !empty($orderAsArray['deliveryNoteFileIds'])) {
+ // Update even if status didn't change but we added linked data
+ WarehouseOrderModel::update($orderAsArray);
}
// Only create a log entry if there's actually something to log
@@ -416,7 +479,11 @@ $appendToBody
WarehouseLogModel::create($log);
}
- self::returnJson(['success' => true, 'message' => 'Log entry created']);
+ self::returnJson([
+ 'success' => true,
+ 'message' => 'Log entry created',
+ 'createdMovementIds' => $createdMovementIds
+ ]);
} catch (Exception $e) {
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
}
@@ -485,6 +552,107 @@ $appendToBody
}
}
+ protected function createMovementsForDelivery(int $orderId, array $deliveryData, int $locationId): array {
+ $order = WarehouseOrderModel::get($orderId);
+ $createdMovementIds = [];
+ foreach ($deliveryData as $delivery) {
+ $deliveredAmount = floatval($delivery['amount']);
+ $articleId = intval($delivery['articleId']);
+
+ // Only create movements for items actually delivered
+ if ($deliveredAmount <= 0 || $articleId <= 0) {
+ continue;
+ }
+
+ // Find or create WarehouseItem for article + location
+ $existingItems = WarehouseItemModel::getAll([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId
+ ]);
+ $warehouseItem = count($existingItems) > 0 ? $existingItems[0] : null;
+
+ if (!$warehouseItem) {
+ // Create new warehouse item with zero quantity
+ $warehouseItemId = WarehouseItemModel::create([
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId,
+ 'quantity' => 0
+ ]);
+ $warehouseItem = WarehouseItemModel::get($warehouseItemId);
+ }
+
+ $quantityBefore = $warehouseItem->quantity;
+ $quantityAfter = $quantityBefore + $deliveredAmount;
+
+ // Create warehouse movement
+ $movementData = [
+ 'movementNumber' => WarehouseMovementModel::generateMovementNumber(),
+ 'movementType' => 'IN',
+ 'articleId' => $articleId,
+ 'warehouseLocationId' => $locationId,
+ 'warehouseItemId' => $warehouseItem->id,
+ 'quantity' => $deliveredAmount,
+ 'quantityBefore' => $quantityBefore,
+ 'quantityAfter' => $quantityAfter,
+ 'reasonCategory' => 'Warenlieferung',
+ 'linkedOrderId' => $orderId,
+ 'note' => "Lagereingang aus Bestellung {$order->orderNumber}",
+ 'userId' => $this->user->id,
+ 'createBy' => $this->user->id,
+ 'create' => time()
+ ];
+
+ $movementId = WarehouseMovementModel::create($movementData);
+ $createdMovementIds[] = $movementId;
+
+ // Update warehouse item quantity
+ $warehouseItem->quantity = $quantityAfter;
+ WarehouseItemModel::update((array)$warehouseItem);
+ }
+
+ return $createdMovementIds;
+ }
+
+ protected function getLinkedMovementsAction() {
+ $orderId = $this->request->orderId;
+ if (empty($orderId)) {
+ self::returnJson(['error' => 'Order ID is required']);
+ return;
+ }
+
+ $order = WarehouseOrderModel::get($orderId);
+ $linkedMovementIds = $order->linkedMovementIds ? json_decode($order->linkedMovementIds, true) : [];
+
+ $movements = [];
+ foreach ($linkedMovementIds as $movementId) {
+ $movement = WarehouseMovementModel::get($movementId);
+ if ($movement) {
+ $article = $movement->getArticle();
+ $location = $movement->getLocation();
+ $movements[] = [
+ 'id' => $movement->id,
+ 'movementNumber' => $movement->movementNumber,
+ 'quantity' => $movement->quantity,
+ 'articleName' => $article ? $article->title : 'Unbekannt',
+ 'locationName' => $location ? $location->title : 'Unbekannt',
+ 'create' => $movement->create
+ ];
+ }
+ }
+
+ self::returnJson($movements);
+ }
+
+ protected function getLocationsAction() {
+ $locations = WarehouseLocationModel::getAll();
+ $result = array_map(function($loc) {
+ return [
+ 'value' => $loc->id,
+ 'text' => $loc->title
+ ];
+ }, $locations);
+ self::returnJson($result);
+ }
}
\ No newline at end of file
diff --git a/application/WarehouseOrder/WarehouseOrderModel.php b/application/WarehouseOrder/WarehouseOrderModel.php
index 4e14082c7..237f55ca2 100644
--- a/application/WarehouseOrder/WarehouseOrderModel.php
+++ b/application/WarehouseOrder/WarehouseOrderModel.php
@@ -32,6 +32,8 @@ class WarehouseOrderModel extends TTCrudBaseModel {
public string $delAddrPLZ;
public int $editor;
public ?string $note;
+ public ?string $linkedMovementIds = null;
+ public ?string $deliveryNoteFileIds = null;
public string $positions;
public ?int $sendShippingNote;
public int $create;
diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php
index 02b07ee85..fbf175801 100644
--- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php
+++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php
@@ -6,7 +6,7 @@ class WarehouseShippingNoteController extends TTCrud {
//@formatter:off
protected array $columns = [
- ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']],
+ ['key' => 'shippingNoteNumber', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap', 'filter' => 'search']],
['key' => 'note', 'text' => 'Art der Arbeit', 'required' => true],
['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => false, 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']],
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [
@@ -56,6 +56,7 @@ class WarehouseShippingNoteController extends TTCrud {
]);
$this->postData['positions'] = json_encode($this->postData['positions']);
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
+ $this->postData['shippingNoteNumber'] = WarehouseShippingNoteModel::generateShippingNoteNumber();
return true;
}
@@ -130,7 +131,10 @@ class WarehouseShippingNoteController extends TTCrud {
// Get billing address info
$billingAddress = null;
if ($shippingNote->billingAddressId) {
- $billingAddress = Address::getOne($shippingNote->billingAddressId);
+ $billingAddress = new Address($shippingNote->billingAddressId);
+ if (!$billingAddress->id) {
+ $billingAddress = null;
+ }
}
// Determine price type ONCE (not in loop for performance)
@@ -486,10 +490,6 @@ class WarehouseShippingNoteController extends TTCrud {
"bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER];
- // Replace placeholders in header
- // create shipping note in this format LS2024-X0001
- // pad number on the left side with zeros
- $shippingNoteNumber = "LS" . date("Y", $shippingNote->create) . "-" . str_pad($shippingNote->id, 4, "0", STR_PAD_LEFT);
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/WarehouseShippingNote/PDF_HEADER.html");
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
$headerHtml = str_replace("{{ addressLine_1 }}", $shippingNote->deliveryAddressName, $headerHtml);
@@ -504,7 +504,7 @@ class WarehouseShippingNoteController extends TTCrud {
$headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml);
$headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml);
$headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml);
- $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml);
+ $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNote->shippingNoteNumber, $headerHtml);
$headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml);
$headerFile = BASEDIR . "/var/temp/shipping-note_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php
index 8cc4e5721..5354951b0 100644
--- a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php
+++ b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php
@@ -2,6 +2,7 @@
class WarehouseShippingNoteModel extends TTCrudBaseModel {
public int $id;
+ public ?string $shippingNoteNumber = null;
public ?int $billingAddressId;
public ?string $type;
public ?string $metadata;
@@ -21,4 +22,23 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
public ?int $eShopOrderId;
public ?int $create;
public ?int $createBy;
+
+ public static function generateShippingNoteNumber(): string {
+ $year = date('Y');
+ $prefix = "LS{$year}-X";
+
+ $db = FronkDB::singleton();
+ $result = $db->query("SELECT shippingNoteNumber FROM WarehouseShippingNote
+ WHERE shippingNoteNumber LIKE '{$prefix}%'
+ ORDER BY shippingNoteNumber DESC LIMIT 1");
+
+ if ($row = $result->fetch_assoc()) {
+ $lastNumber = intval(substr($row['shippingNoteNumber'], -4));
+ $nextNumber = $lastNumber + 1;
+ } else {
+ $nextNumber = 1;
+ }
+
+ return $prefix . str_pad((string)$nextNumber, 4, '0', STR_PAD_LEFT);
+ }
}
\ No newline at end of file
diff --git a/application/Workorder/WorkorderModel.php b/application/Workorder/WorkorderModel.php
index b5762a147..680e51244 100644
--- a/application/Workorder/WorkorderModel.php
+++ b/application/Workorder/WorkorderModel.php
@@ -16,6 +16,7 @@ class WorkorderModel extends TTCrudBaseModel
public ?string $additionalInfo;
public ?string $cableLength;
public ?string $cableType;
+ public ?string $metadata;
public int $create;
public int $createBy;
@@ -199,4 +200,62 @@ class WorkorderModel extends TTCrudBaseModel
$result = $db->query($sql);
return $result ? $result->fetch_assoc()['count'] : 0;
}
+
+ public static function getTechnicalData(int $workorderId): ?array {
+ $workorder = self::get($workorderId);
+ if (!$workorder || !$workorder->preorderId) return null;
+
+ $preorder = new Preorder($workorder->preorderId);
+ if (!$preorder->id || !$preorder->adb_wohneinheit_id) return null;
+
+ $wohneinheit = $preorder->adb_wohneinheit;
+ if (!$wohneinheit) return null;
+
+ $defaultCluster = '';
+ if ($preorder->adb_hausnummer && $preorder->adb_hausnummer->netzgebiet) {
+ $defaultCluster = $preorder->adb_hausnummer->netzgebiet->extref ?? '';
+ }
+
+ $patchposition = [
+ 'equipmentName' => $wohneinheit->getPatchEqString(),
+ 'equipmentPort' => $wohneinheit->patch_port,
+ 'cluster' => $wohneinheit->patch_cluster ?: $defaultCluster,
+ 'shelf' => $wohneinheit->patch_shelf,
+ 'module' => $wohneinheit->patch_module,
+ ];
+
+ // Get dropcable data from metadata
+ $dropkabelData = [];
+ $ahaParsed = null;
+ $mapFile = null;
+ if (!empty($workorder->metadata)) {
+ $metadata = json_decode($workorder->metadata, true);
+ if (!empty($metadata['dropcable'])) {
+ $ahaParsed = $metadata['dropcable']['parsed_at'] ?? null;
+ $dropkabelData = $metadata['dropcable']['entries'] ?? [];
+ if ($mapFileId = $metadata['dropcable']['map_file_id'] ?? null) {
+ $file = new File($mapFileId);
+ if ($file->id) {
+ $mapFile = ['id' => $file->id, 'name' => $file->name, 'download_url' => '/File/show?id=' . $file->id];
+ }
+ }
+ }
+ }
+
+ $rimoWorkorders = [];
+ if (is_array($wohneinheit->rimo_workorders) && count($wohneinheit->rimo_workorders)) {
+ foreach ($wohneinheit->rimo_workorders as $wo) {
+ $rimoWorkorders[] = [
+ 'id' => $wo->id, 'rimoName' => $wo->rimo_name, 'rimoId' => $wo->rimo_id,
+ 'rimoStatus' => $wo->rimo_status, 'downloadUrl' => "/RimoWorkorder/downloadAha?id=" . $wo->id,
+ ];
+ }
+ }
+
+ return [
+ 'patchposition' => $patchposition,
+ 'rimoWorkorders' => $rimoWorkorders,
+ 'dropcable' => ['parsed_at' => $ahaParsed, 'entries' => $dropkabelData, 'map_file' => $mapFile],
+ ];
+ }
}
\ No newline at end of file
diff --git a/application/WorkorderAdmin/WorkorderAdminController.php b/application/WorkorderAdmin/WorkorderAdminController.php
index c0771036f..40259cb0b 100644
--- a/application/WorkorderAdmin/WorkorderAdminController.php
+++ b/application/WorkorderAdmin/WorkorderAdminController.php
@@ -54,6 +54,7 @@ class WorkorderAdminController extends WorkorderBaseController
public function indexAction()
{
$this->createWorkordersFromPreorders();
+ $this->autoCompleteDocumentedWorkorders();
$this->archiveWorkorders();
parent::indexAction();
}
diff --git a/application/WorkorderBase/WorkorderBaseController.php b/application/WorkorderBase/WorkorderBaseController.php
index fcfce3a08..8fac50f33 100644
--- a/application/WorkorderBase/WorkorderBaseController.php
+++ b/application/WorkorderBase/WorkorderBaseController.php
@@ -60,6 +60,10 @@ class WorkorderBaseController extends TTCrud
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
}
+ // Auto-parse AHA if enabled and not yet parsed
+ if ($tenantConfig?->showTechnicalData) {
+ RimoWorkorder::autoParseForWorkorder((int)$this->request->workorderId);
+ }
$responseDocs = [];
$typeCounts = [];
@@ -141,6 +145,13 @@ class WorkorderBaseController extends TTCrud
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null;
}
+ /**
+ * Retrieves technical data (patchposition and AHA Blatt info) for a workorder.
+ */
+ protected function getTechnicalData(int $workorderId): ?array {
+ return WorkorderModel::getTechnicalData($workorderId);
+ }
+
//region BACKGROUND TASKS
/**
* Creates new workorders from preorders based on tenant configurations.
@@ -272,5 +283,50 @@ class WorkorderBaseController extends TTCrud
}
file_put_contents($lockFile, time());
}
+
+ protected function autoCompleteDocumentedWorkorders()
+ {
+ $lockFile = TEMP_DIR . "/task_auto_complete_workorders.lock";
+ if (file_exists($lockFile) && (time() - intval(file_get_contents($lockFile))) < 300) return;
+
+ foreach (WorkorderTenantConfigModel::getAll() as $config) {
+ $filter = json_decode($config->autoCompleteFilter ?? '', true);
+ if (empty($filter)) continue;
+
+ $networks = NetworkModel::search(['owner_id' => $config->addressId]);
+ if (empty($networks)) continue;
+
+ $networkIds = array_map(fn($n) => $n->id, $networks);
+ $campaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => $networkIds]));
+ if (empty($campaignIds)) continue;
+
+ $filter['preordercampaign_id'] = $campaignIds;
+ $matchingPreorders = PreorderModel::searchActive($filter);
+ if (empty($matchingPreorders)) continue;
+
+ $preorderIds = array_map(fn($p) => $p->id, $matchingPreorders);
+ $preorderIdSet = array_flip($preorderIds);
+
+ $workorders = WorkorderModel::getAll([
+ 'status' => ['new', 'assigned', 'scheduled', 'in_progress', 'correction_requested', 'intervention_required', 'civil_engineering_required', 'civil_engineering_completed', 'problem_solved', 'documented', 'archived'],
+ 'preorderId' => $preorderIds
+ ]);
+
+ foreach ($workorders as $workorder) {
+ if (!isset($preorderIdSet[$workorder->preorderId])) continue;
+ $oldStatus = $workorder->status;
+ $workorder->status = 'completed';
+ WorkorderModel::update((array)$workorder);
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id,
+ 'text' => 'Dokumentation automatisch akzeptiert (Auto-Complete Filter).',
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
+ 'create' => time(),
+ 'createBy' => 1,
+ ]);
+ }
+ }
+ file_put_contents($lockFile, time());
+ }
//endregion
}
diff --git a/application/WorkorderCompany/WorkorderCompanyController.php b/application/WorkorderCompany/WorkorderCompanyController.php
index 6125b6e81..fd6f93d53 100644
--- a/application/WorkorderCompany/WorkorderCompanyController.php
+++ b/application/WorkorderCompany/WorkorderCompanyController.php
@@ -121,6 +121,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
}
+ protected function clearAppointmentAction() {
+ if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
+ $workorder = WorkorderModel::get($this->postData['workorderId']);
+ if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
+
+ $oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
+ $workorder->appointmentDate = null;
+ $workorder->status = 'assigned';
+ WorkorderModel::update((array)$workorder);
+
+ WorkorderJournalModel::create([
+ 'workorderId' => $workorder->id, 'text' => "Termin gelöscht (war: {$oldDateFormatted}).",
+ 'create' => time(), 'createBy' => $this->user->id,
+ ]);
+ self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gelöscht.']);
+ }
+
protected function requestInterventionAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
@@ -167,14 +184,23 @@ class WorkorderCompanyController extends WorkorderBaseController {
self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']);
return;
}
- self::returnJson([
+
+ $response = [
'success' => true,
'documentationTypes' => json_decode($tenantConfig->documentationTypes, true),
'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired,
'interventionTypes' => json_decode($tenantConfig->interventionTypes, true),
'requireCableLength' => $tenantConfig->requireCableLength,
- 'requireCableType' => $tenantConfig->requireCableType
- ]);
+ 'requireCableType' => $tenantConfig->requireCableType,
+ 'showTechnicalData' => (bool)$tenantConfig->showTechnicalData,
+ 'tiefbauSeesNormalDocs' => (bool)$tenantConfig->tiefbauSeesNormalDocs,
+ ];
+
+ if ($tenantConfig->showTechnicalData) {
+ $response['technicalData'] = $this->getTechnicalData((int)$this->request->workorderId);
+ }
+
+ self::returnJson($response);
}
protected function uploadDocumentationAction() {
diff --git a/application/WorkorderDashboard/WorkorderDashboardController.php b/application/WorkorderDashboard/WorkorderDashboardController.php
new file mode 100644
index 000000000..204609ee8
--- /dev/null
+++ b/application/WorkorderDashboard/WorkorderDashboardController.php
@@ -0,0 +1,304 @@
+",
+ "",
+ "",
+ ""
+ ];
+
+ protected array $statusLabels = [
+ 'new' => 'Neu', 'assigned' => 'Zugewiesen', 'scheduled' => 'Geplant', 'in_progress' => 'In Bearbeitung',
+ 'correction_requested' => 'Korrektur angefordert', 'intervention_required' => 'Eingriff erforderlich',
+ 'civil_engineering_required' => 'Tiefbau benötigt', 'civil_engineering_completed' => 'Tiefbau abgeschlossen',
+ 'problem_solved' => 'Problem gelöst', 'documented' => 'Dokumentiert', 'completed' => 'Abgeschlossen',
+ 'charged' => 'Verrechnet', 'cancelled' => 'Abgebrochen', 'archived' => 'Archiviert',
+ ];
+
+ protected array $statusColors = [
+ 'new' => '#3b82f6', 'assigned' => '#06b6d4', 'scheduled' => '#8b5cf6', 'in_progress' => '#f59e0b',
+ 'correction_requested' => '#ef4444', 'intervention_required' => '#dc2626', 'civil_engineering_required' => '#ea580c',
+ 'civil_engineering_completed' => '#65a30d', 'problem_solved' => '#22c55e', 'documented' => '#14b8a6',
+ 'completed' => '#10b981', 'charged' => '#8b5cf6', 'cancelled' => '#6b7280', 'archived' => '#9ca3af',
+ ];
+
+ protected function indexAction()
+ {
+ $this->layout()->set('additionalHead', $this->additionalHead);
+ Helper::renderVue($this, 'WorkorderDashboard', $this->headerTitle, []);
+ }
+
+ protected function getFilterOptionsAction()
+ {
+ if ($this->user->isAdmin()) {
+ $tenants = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
+ } else {
+ $tenants = WorkorderTenantConfigModel::getAll(['addressId' => $this->user->address_id], null, 0, ['key' => 'name', 'order' => 'ASC']);
+ }
+ $companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
+
+ self::returnJson([
+ 'tenants' => array_map(fn($t) => ['value' => $t->id, 'text' => $t->name], $tenants),
+ 'companies' => array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies),
+ 'statuses' => array_map(fn($key, $label) => ['value' => $key, 'text' => $label], array_keys($this->statusLabels), $this->statusLabels),
+ 'campaigns' => []
+ ]);
+ }
+
+ protected function getCampaignsForTenantAction()
+ {
+ $tenantId = $this->postData['tenantId'] ?? null;
+ if (!$tenantId || !($config = WorkorderTenantConfigModel::get($tenantId))) {
+ self::returnJson([]);
+ return;
+ }
+ if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) {
+ self::returnJson([]);
+ return;
+ }
+
+ $networks = NetworkModel::search(['owner_id' => $config->addressId]);
+ if (empty($networks)) {
+ self::returnJson([]);
+ return;
+ }
+
+ $campaigns = PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]);
+ $options = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns);
+ usort($options, fn($a, $b) => strcmp($a['text'], $b['text']));
+ self::returnJson($options);
+ }
+
+ protected function getDashboardDataAction()
+ {
+ $tenantId = $this->postData['tenantId'] ?? null;
+ $dateFrom = $this->postData['dateFrom'] ?? null;
+ $dateTo = $this->postData['dateTo'] ?? null;
+ $companyIds = $this->postData['companyIds'] ?? [];
+ $statuses = $this->postData['statuses'] ?? [];
+ $campaignIds = $this->postData['campaignIds'] ?? [];
+
+ if (!$tenantId) self::sendError('Mandant muss ausgewählt werden.');
+ $config = WorkorderTenantConfigModel::get($tenantId);
+ if (!$config) self::sendError('Mandant nicht gefunden.');
+ if (!$this->user->isAdmin() && $config->addressId != $this->user->address_id) self::sendError('Keine Berechtigung für diesen Mandanten.');
+
+ $networks = NetworkModel::search(['owner_id' => $config->addressId]);
+ $tenantCampaignIds = array_map(fn($c) => $c->id, PreordercampaignModel::search(['network_id' => array_map(fn($n) => $n->id, $networks)]));
+
+ if (empty($tenantCampaignIds)) {
+ self::returnJson($this->getEmptyDashboardData());
+ return;
+ }
+
+ if (!empty($campaignIds)) $tenantCampaignIds = array_intersect($tenantCampaignIds, $campaignIds);
+
+ $db = FronkDB::singleton();
+ $whereConditions = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
+ if ($dateFrom) $whereConditions[] = "w.`create` >= " . intval($dateFrom);
+ if ($dateTo) $whereConditions[] = "w.`create` <= " . intval($dateTo);
+ if (!empty($companyIds)) $whereConditions[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
+ if (!empty($statuses)) $whereConditions[] = "w.status IN (" . implode(',', array_map(fn($s) => "'" . $db->escape($s) . "'", $statuses)) . ")";
+ $whereClause = implode(' AND ', $whereConditions);
+
+ self::returnJson([
+ 'kpis' => $this->getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo),
+ 'statusDistribution' => $this->getStatusDistribution($db, $whereClause),
+ 'companyPerformance' => $this->getCompanyPerformance($db, $whereClause),
+ 'timeTrends' => $this->getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds),
+ 'companyStatusCampaign' => $this->getCompanyStatusCampaign($db, $whereClause),
+ 'interventionRates' => $this->getInterventionRates($db, $whereClause),
+ 'statusTransitions' => $this->getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo),
+ ]);
+ }
+
+ private function getKPIs($db, $whereClause, $tenantCampaignIds, $dateFrom, $dateTo): array
+ {
+ $total = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause")->fetch_assoc()['c'] ?? 0;
+ $offen = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived')")->fetch_assoc()['c'] ?? 0;
+ $terminisiert = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status NOT IN ('completed', 'charged', 'archived') AND w.appointmentDate IS NOT NULL")->fetch_assoc()['c'] ?? 0;
+ $issues = $db->query("SELECT COUNT(*) as c FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause AND w.status IN ('intervention_required', 'correction_requested')")->fetch_assoc()['c'] ?? 0;
+
+ return [
+ 'total' => (int)$total,
+ 'offen' => (int)$offen,
+ 'terminisiert' => (int)$terminisiert,
+ 'issues' => (int)$issues,
+ 'interventionRate' => $total > 0 ? round(($issues / $total) * 100, 1) : 0,
+ 'avgCompletionDays' => $this->calculateAvgCompletionTime($db, $tenantCampaignIds),
+ ];
+ }
+
+ private function calculateAvgCompletionTime($db, $tenantCampaignIds): ?float
+ {
+ $sql = "SELECT w.id,
+ MIN(CASE WHEN wj.statusChange LIKE '%Zugewiesen%' OR wj.statusChange LIKE '%-> Zugewiesen' THEN wj.`create` END) as assigned_time,
+ MIN(CASE WHEN wj.statusChange LIKE '%-> Abgeschlossen%' THEN wj.`create` END) as completed_time
+ FROM thetool.Workorder w
+ JOIN thetool.Preorder p ON w.preorderId = p.id
+ JOIN thetool.WorkorderJournal wj ON w.id = wj.workorderId
+ WHERE p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ") AND w.status IN ('completed', 'charged')
+ GROUP BY w.id HAVING assigned_time IS NOT NULL AND completed_time IS NOT NULL";
+
+ $result = $db->query($sql);
+ $totalDays = $count = 0;
+ while ($row = $result->fetch_assoc()) {
+ if ($row['completed_time'] > $row['assigned_time']) {
+ $totalDays += ($row['completed_time'] - $row['assigned_time']) / 86400;
+ $count++;
+ }
+ }
+ return $count > 0 ? round($totalDays / $count, 1) : null;
+ }
+
+ private function getStatusDistribution($db, $whereClause): array
+ {
+ $result = $db->query("SELECT w.status, COUNT(*) as count FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id WHERE $whereClause GROUP BY w.status ORDER BY count DESC");
+ $distribution = [];
+ while ($row = $result->fetch_assoc()) {
+ $distribution[] = [
+ 'status' => $row['status'],
+ 'label' => $this->statusLabels[$row['status']] ?? $row['status'],
+ 'count' => (int)$row['count'],
+ 'color' => $this->statusColors[$row['status']] ?? '#6b7280',
+ ];
+ }
+ return $distribution;
+ }
+
+ private function getCompanyPerformance($db, $whereClause): array
+ {
+ $sql = "SELECT wc.name as company, wc.id as companyId,
+ SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed,
+ SUM(CASE WHEN w.status IN ('new', 'assigned', 'scheduled', 'in_progress', 'documented') THEN 1 ELSE 0 END) as pending,
+ SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) as issues,
+ COUNT(*) as total
+ FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
+ LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
+ WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.id, wc.name ORDER BY total DESC";
+
+ $result = $db->query($sql);
+ $performance = [];
+ while ($row = $result->fetch_assoc()) {
+ $performance[] = [
+ 'company' => $row['company'] ?? 'Nicht zugewiesen',
+ 'companyId' => (int)$row['companyId'],
+ 'completed' => (int)$row['completed'],
+ 'pending' => (int)$row['pending'],
+ 'issues' => (int)$row['issues'],
+ 'total' => (int)$row['total'],
+ ];
+ }
+ return $performance;
+ }
+
+ private function getTimeTrends($db, $tenantCampaignIds, $dateFrom, $dateTo, $companyIds): array
+ {
+ $where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")"];
+ if ($dateFrom) $where[] = "w.`create` >= " . intval($dateFrom);
+ if ($dateTo) $where[] = "w.`create` <= " . intval($dateTo);
+ if (!empty($companyIds)) $where[] = "w.companyId IN (" . implode(',', array_map('intval', $companyIds)) . ")";
+
+ $sql = "SELECT DATE(FROM_UNIXTIME(w.`create`)) as date, COUNT(*) as created,
+ SUM(CASE WHEN w.status IN ('completed', 'charged') THEN 1 ELSE 0 END) as completed
+ FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
+ WHERE " . implode(' AND ', $where) . " GROUP BY DATE(FROM_UNIXTIME(w.`create`)) ORDER BY date ASC";
+
+ $result = $db->query($sql);
+ $trends = [];
+ while ($row = $result->fetch_assoc()) {
+ $trends[] = ['date' => $row['date'], 'created' => (int)$row['created'], 'completed' => (int)$row['completed']];
+ }
+ return $trends;
+ }
+
+ private function getCompanyStatusCampaign($db, $whereClause): array
+ {
+ $sql = "SELECT wc.name as company, w.status, pc.name as campaign, COUNT(*) as count
+ FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
+ JOIN thetool.Preordercampaign pc ON p.preordercampaign_id = pc.id
+ LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
+ WHERE $whereClause AND w.companyId IS NOT NULL
+ GROUP BY wc.name, w.status, pc.name ORDER BY wc.name, count DESC";
+
+ $result = $db->query($sql);
+ $data = [];
+ while ($row = $result->fetch_assoc()) {
+ $key = ($row['company'] ?? 'Nicht zugewiesen') . '|' . $row['status'];
+ if (!isset($data[$key])) {
+ $data[$key] = [
+ 'company' => $row['company'] ?? 'Nicht zugewiesen',
+ 'status' => $row['status'],
+ 'statusLabel' => $this->statusLabels[$row['status']] ?? $row['status'],
+ 'count' => 0,
+ 'campaigns' => [],
+ ];
+ }
+ $data[$key]['count'] += (int)$row['count'];
+ $data[$key]['campaigns'][] = ['name' => $row['campaign'], 'count' => (int)$row['count']];
+ }
+
+ $result = array_values($data);
+ usort($result, fn($a, $b) => $b['count'] - $a['count']);
+ return $result;
+ }
+
+ private function getInterventionRates($db, $whereClause): array
+ {
+ $sql = "SELECT wc.name as company, COUNT(*) as total,
+ SUM(CASE WHEN w.status = 'intervention_required' THEN 1 ELSE 0 END) as interventions,
+ SUM(CASE WHEN w.status = 'correction_requested' THEN 1 ELSE 0 END) as corrections
+ FROM thetool.Workorder w JOIN thetool.Preorder p ON w.preorderId = p.id
+ LEFT JOIN thetool.WorkorderCompany wc ON w.companyId = wc.id
+ WHERE $whereClause AND w.companyId IS NOT NULL GROUP BY wc.name
+ HAVING COUNT(*) >= 5 ORDER BY (SUM(CASE WHEN w.status IN ('intervention_required', 'correction_requested') THEN 1 ELSE 0 END) / COUNT(*)) DESC";
+
+ $result = $db->query($sql);
+ $rates = [];
+ while ($row = $result->fetch_assoc()) {
+ $total = (int)$row['total'];
+ $issueCount = (int)$row['interventions'] + (int)$row['corrections'];
+ $rates[] = [
+ 'company' => $row['company'] ?? 'Nicht zugewiesen',
+ 'total' => $total,
+ 'interventions' => (int)$row['interventions'],
+ 'corrections' => (int)$row['corrections'],
+ 'rate' => $total > 0 ? round(($issueCount / $total) * 100, 1) : 0,
+ ];
+ }
+ return $rates;
+ }
+
+ private function getStatusTransitions($db, $tenantCampaignIds, $dateFrom, $dateTo): array
+ {
+ $where = ["p.preordercampaign_id IN (" . implode(',', $tenantCampaignIds) . ")", "wj.statusChange IS NOT NULL"];
+ if ($dateFrom) $where[] = "wj.`create` >= " . intval($dateFrom);
+ if ($dateTo) $where[] = "wj.`create` <= " . intval($dateTo);
+
+ $sql = "SELECT wj.statusChange, COUNT(*) as count FROM thetool.WorkorderJournal wj
+ JOIN thetool.Workorder w ON wj.workorderId = w.id JOIN thetool.Preorder p ON w.preorderId = p.id
+ WHERE " . implode(' AND ', $where) . " GROUP BY wj.statusChange ORDER BY count DESC LIMIT 15";
+
+ $result = $db->query($sql);
+ $transitions = [];
+ while ($row = $result->fetch_assoc()) {
+ $transitions[] = ['transition' => $row['statusChange'], 'count' => (int)$row['count']];
+ }
+ return $transitions;
+ }
+
+ private function getEmptyDashboardData(): array
+ {
+ return [
+ 'kpis' => ['total' => 0, 'offen' => 0, 'terminisiert' => 0, 'issues' => 0, 'interventionRate' => 0, 'avgCompletionDays' => null],
+ 'statusDistribution' => [], 'companyPerformance' => [], 'timeTrends' => [],
+ 'companyStatusCampaign' => [], 'interventionRates' => [], 'statusTransitions' => [],
+ ];
+ }
+}
diff --git a/application/WorkorderMphAdmin/WorkorderMphAdminController.php b/application/WorkorderMphAdmin/WorkorderMphAdminController.php
index 416d01d6d..efe5aec14 100644
--- a/application/WorkorderMphAdmin/WorkorderMphAdminController.php
+++ b/application/WorkorderMphAdmin/WorkorderMphAdminController.php
@@ -375,4 +375,38 @@ class WorkorderMphAdminController extends WorkorderMphBaseController
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
}
+
+ protected function unassignWorkorderAction()
+ {
+ if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
+
+ $workorder = WorkorderMphModel::get($this->postData['workorderId']);
+ if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
+
+ if ($workorder->status === 'new') self::sendError("Arbeitsauftrag ist nicht zugewiesen.");
+ if (in_array($workorder->status, ['completed', 'cancelled'])) self::sendError("Arbeitsauftrag kann nicht mehr geändert werden.");
+
+ $oldStatus = $workorder->status;
+ $oldCompany = $workorder->companyId ? WorkorderCompanyModel::get($workorder->companyId) : null;
+ $oldCompanyName = $oldCompany ? $oldCompany->name : 'Unbekannt';
+
+ $workorder->status = 'new';
+ $workorder->companyId = null;
+ $workorder->assignmentDate = null;
+ $workorder->deadlineDate = null;
+ $workorder->appointmentDate = null;
+ WorkorderMphModel::update((array)$workorder);
+
+ $reason = !empty($this->postData['reason']) ? " Grund: " . $this->postData['reason'] : '';
+
+ WorkorderMphJournalModel::create([
+ 'workorderMphId' => $workorder->id,
+ 'text' => "Zuweisung aufgehoben (vorher: $oldCompanyName).$reason",
+ 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('new'),
+ 'create' => time(),
+ 'createBy' => $this->user->id,
+ ]);
+
+ self::returnJson(['success' => true, 'message' => 'Zuweisung wurde aufgehoben.']);
+ }
}
diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php
index 8a7dc0099..064fd2787 100644
--- a/application/WorkorderTenantConfig/WorkorderTenantConfigController.php
+++ b/application/WorkorderTenantConfig/WorkorderTenantConfigController.php
@@ -20,6 +20,7 @@ class WorkorderTenantConfigController extends TTCrud {
$data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
$data['workorderCreationFilters'] ??= '{}';
$data['workorderActiveFilters'] ??= '{}';
+ $data['autoCompleteFilter'] ??= null;
if (empty($data['id'])) {
$data['create'] = time();
diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php
index bc917678b..723830a83 100644
--- a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php
+++ b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php
@@ -8,10 +8,13 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel {
public string $documentationTypes; // JSON
public string $workorderCreationFilters; // JSON
public ?string $workorderActiveFilters; // JSON
+ public ?string $autoCompleteFilter; // JSON
public ?string $interventionTypes; // JSON
public int $civilEngineeringDocsRequired;
public int $requireCableLength;
public int $requireCableType;
+ public int $showTechnicalData = 0;
+ public int $tiefbauSeesNormalDocs = 0;
public int $enableWorkorder;
public int $enableWorkorderMph;
public int $create;
diff --git a/composer.json b/composer.json
index 151670a4a..f4603b66e 100644
--- a/composer.json
+++ b/composer.json
@@ -10,6 +10,7 @@
"stomp-php/stomp-php": "^5",
"phpmailer/phpmailer": "^6.9",
"pear2/net_routeros": "dev-develop@dev",
- "matthiasmullie/minify": "^1.3"
+ "matthiasmullie/minify": "^1.3",
+ "smalot/pdfparser": "^2.0"
}
}
diff --git a/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php
new file mode 100644
index 000000000..c2a09bfe7
--- /dev/null
+++ b/db/migrations/20260113120000_add_lock_exported_to_manualinvoice.php
@@ -0,0 +1,39 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("ManualInvoice");
+
+ $table->addColumn("lock", "integer", [
+ "null" => false,
+ "default" => 0,
+ "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ "after" => "status"
+ ]);
+
+ $table->addColumn("exported", "integer", [
+ "null" => false,
+ "default" => 0,
+ "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY,
+ "after" => "lock"
+ ]);
+
+ $table->save();
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+ $table = $this->table("ManualInvoice");
+ $table->removeColumn("lock")->save();
+ $table->removeColumn("exported")->save();
+ }
+ }
+}
diff --git a/db/migrations/20260113130000_create_warehouse_lagerbewegung.php b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php
new file mode 100644
index 000000000..fea8726bf
--- /dev/null
+++ b/db/migrations/20260113130000_create_warehouse_lagerbewegung.php
@@ -0,0 +1,42 @@
+getEnvironment() == "thetool") {
+ $lagerbewegung = $this->table('WarehouseLagerbewegung');
+ $lagerbewegung
+ ->addColumn('movementNumber', 'string', ['limit' => 50, 'null' => true])
+ ->addColumn('movementType', 'enum', ['values' => ['IN', 'OUT', 'ADJUSTMENT']])
+ ->addColumn('articleId', 'integer', ['signed' => false])
+ ->addColumn('warehouseLocationId', 'integer', ['signed' => true])
+ ->addColumn('warehouseItemId', 'integer', ['null' => true, 'signed' => true])
+ ->addColumn('quantity', 'decimal', ['precision' => 10, 'scale' => 2])
+ ->addColumn('quantityBefore', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
+ ->addColumn('quantityAfter', 'decimal', ['precision' => 10, 'scale' => 2, 'null' => true])
+ ->addColumn('reasonCategory', 'string', ['limit' => 50])
+ ->addColumn('note', 'text', ['null' => true])
+ ->addColumn('userId', 'integer', ['signed' => false])
+ ->addColumn('createBy', 'integer', ['signed' => false])
+ ->addColumn('create', 'integer')
+ ->addIndex(['movementNumber'], ['unique' => true])
+ ->addIndex(['articleId'])
+ ->addIndex(['warehouseLocationId'])
+ ->addIndex(['movementType'])
+ ->addIndex(['userId'])
+ ->addIndex(['create'])
+ ->create();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $this->table('WarehouseLagerbewegung')->drop()->save();
+ }
+ }
+}
diff --git a/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php
new file mode 100644
index 000000000..b368d3754
--- /dev/null
+++ b/db/migrations/20260113140000_rename_lagerbewegung_to_movement.php
@@ -0,0 +1,21 @@
+getEnvironment() == "thetool") {
+ $this->table('WarehouseLagerbewegung')->rename('WarehouseMovement')->save();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $this->table('WarehouseMovement')->rename('WarehouseLagerbewegung')->save();
+ }
+ }
+}
diff --git a/db/migrations/20260115121818_preorder_ctag_add_ext_name.php b/db/migrations/20260115121818_preorder_ctag_add_ext_name.php
new file mode 100644
index 000000000..d225a7036
--- /dev/null
+++ b/db/migrations/20260115121818_preorder_ctag_add_ext_name.php
@@ -0,0 +1,35 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("PreorderCtag");
+ $table->renameColumn("ext_id", "ext_name");
+ $table->addColumn("ext_id", "string", ["null" => true, "default" => null, "length" => 255, "after" => "service_type"]);
+ $table->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+ $table = $this->table("PreorderCtag");
+ $table->removeColumn("ext_id");
+ $table->renameColumn("ext_name", "ext_id");
+ $table->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+}
diff --git a/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php b/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php
new file mode 100644
index 000000000..f6e7381d7
--- /dev/null
+++ b/db/migrations/20260115165426_voicenumber_add_disable_reason_canceled.php
@@ -0,0 +1,33 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("Voicenumber");
+ $table->changeColumn("disabled", "integer", ["null" => false, "default" => 0]);
+ $table->changeColumn("disabled_reason", "enum", ["values" => "ported_out,ported_back,reserved,legacy,damaged,contract_cancelled", "null" => true, "default" => null]);
+ $table->addColumn("disabled_by", "integer", ["null" => true, "default" => null, "after" => "disabled_reason"]);
+ $table->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+}
diff --git a/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php b/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php
new file mode 100644
index 000000000..6ef69f1ae
--- /dev/null
+++ b/db/migrations/20260115184942_address_add_manual_invoice_sepa_limit.php
@@ -0,0 +1,31 @@
+getEnvironment() == "thetool") {
+ $table = $this->table('Address');
+ $table->addColumn("manual_invoice_sepa_limit", "decimal", ["null" => true, "default" => 500, "precision" => 9, "scale" => 2, "after" => "fibu_payment_skonto_rate"]);
+ $table->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+ $this->table('Address')->removeColumn("manual_invoice_sepa_limit")->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+}
diff --git a/db/migrations/20260116152525_order_product_add_preorder_data.php b/db/migrations/20260116152525_order_product_add_preorder_data.php
new file mode 100644
index 000000000..6afa39742
--- /dev/null
+++ b/db/migrations/20260116152525_order_product_add_preorder_data.php
@@ -0,0 +1,33 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("OrderProduct");
+ $table->addColumn("oaid", "string", ["null" => true, "default" => null, "limit" => 255, "after" => "termination_id"]);
+ $table->addColumn("preorder_id", "integer", ["null" => true, "default" => null, "after" => "oaid"]);
+ $table->addColumn("snopp_order_id", "integer", ["null" => true, "default" => null, "after" => "preorder_id"]);
+ $table->update();
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+
+ }
+
+ if($this->getEnvironment() == "addressdb") {
+
+ }
+ }
+}
diff --git a/db/migrations/20260117120000_add_shipping_note_number.php b/db/migrations/20260117120000_add_shipping_note_number.php
new file mode 100644
index 000000000..ba76c9224
--- /dev/null
+++ b/db/migrations/20260117120000_add_shipping_note_number.php
@@ -0,0 +1,50 @@
+getEnvironment() == "thetool") {
+ $table = $this->table('WarehouseShippingNote');
+ $table
+ ->addColumn('shippingNoteNumber', 'string', ['limit' => 20, 'null' => true, 'after' => 'id'])
+ ->addIndex(['shippingNoteNumber'], ['unique' => true])
+ ->update();
+
+ // Get all shipping notes ordered by create timestamp to assign numbers in chronological order
+ $rows = $this->fetchAll(
+ "SELECT id, YEAR(FROM_UNIXTIME(`create`)) as year
+ FROM WarehouseShippingNote
+ ORDER BY `create` ASC, id ASC"
+ );
+
+ // Group by year and assign sequential numbers
+ $yearCounters = [];
+ foreach ($rows as $row) {
+ $year = $row['year'];
+ if (!isset($yearCounters[$year])) {
+ $yearCounters[$year] = 0;
+ }
+ $yearCounters[$year]++;
+
+ $shippingNoteNumber = 'LS' . $year . '-X' . str_pad((string)$yearCounters[$year], 4, '0', STR_PAD_LEFT);
+
+ $this->execute(
+ "UPDATE WarehouseShippingNote SET shippingNoteNumber = '{$shippingNoteNumber}' WHERE id = {$row['id']}"
+ );
+ }
+
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $table = $this->table('WarehouseShippingNote');
+ $table->removeColumn('shippingNoteNumber')->update();
+ }
+ }
+}
diff --git a/db/migrations/20260117150000_add_order_movement_linking.php b/db/migrations/20260117150000_add_order_movement_linking.php
new file mode 100644
index 000000000..6a6d9a31b
--- /dev/null
+++ b/db/migrations/20260117150000_add_order_movement_linking.php
@@ -0,0 +1,43 @@
+getEnvironment() == "thetool") {
+ // Add columns to WarehouseOrder for linking to movements and delivery note files
+ $orderTable = $this->table('WarehouseOrder');
+ $orderTable
+ ->addColumn('linkedMovementIds', 'text', ['null' => true, 'after' => 'note'])
+ ->addColumn('deliveryNoteFileIds', 'text', ['null' => true, 'after' => 'linkedMovementIds'])
+ ->update();
+
+ // Add column to WarehouseMovement for linking back to orders
+ $movementTable = $this->table('WarehouseMovement');
+ $movementTable
+ ->addColumn('linkedOrderId', 'integer', ['null' => true, 'signed' => false, 'after' => 'note'])
+ ->addIndex(['linkedOrderId'], ['name' => 'idx_linkedOrderId'])
+ ->update();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $orderTable = $this->table('WarehouseOrder');
+ $orderTable
+ ->removeColumn('linkedMovementIds')
+ ->removeColumn('deliveryNoteFileIds')
+ ->update();
+
+ $movementTable = $this->table('WarehouseMovement');
+ $movementTable
+ ->removeIndex(['linkedOrderId'])
+ ->removeColumn('linkedOrderId')
+ ->update();
+ }
+ }
+}
diff --git a/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php b/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php
new file mode 100644
index 000000000..4bd3e16c9
--- /dev/null
+++ b/db/migrations/20260118120000_add_show_technical_data_to_tenant_config.php
@@ -0,0 +1,26 @@
+getEnvironment() !== "thetool") return;
+
+ $table = $this->table('WorkorderTenantConfig');
+ if (!$table->hasColumn('showTechnicalData')) {
+ $table->addColumn('showTechnicalData', 'boolean', [
+ 'default' => false,
+ 'after' => 'requireCableType'
+ ])->update();
+ }
+ }
+
+ public function down() {
+ if ($this->getEnvironment() !== "thetool") return;
+
+ $table = $this->table('WorkorderTenantConfig');
+ if ($table->hasColumn('showTechnicalData')) {
+ $table->removeColumn('showTechnicalData')->update();
+ }
+ }
+}
diff --git a/db/migrations/20260119140000_asset_management_add_category.php b/db/migrations/20260119140000_asset_management_add_category.php
new file mode 100644
index 000000000..47dac3c34
--- /dev/null
+++ b/db/migrations/20260119140000_asset_management_add_category.php
@@ -0,0 +1,33 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("AssetManagement");
+ $table->addColumn('category', 'string', [
+ 'limit' => 255,
+ 'null' => true,
+ 'default' => null,
+ 'after' => 'description',
+ 'comment' => 'Free text category for the asset with autocomplete support',
+ ]);
+ $table->addIndex(['category'], ['name' => 'idx_category']);
+ $table->update();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $table = $this->table("AssetManagement");
+ $table->removeIndex(['category']);
+ $table->removeColumn('category');
+ $table->update();
+ }
+ }
+}
diff --git a/db/migrations/20260119170000_add_metadata_to_workorder.php b/db/migrations/20260119170000_add_metadata_to_workorder.php
new file mode 100644
index 000000000..a24126bef
--- /dev/null
+++ b/db/migrations/20260119170000_add_metadata_to_workorder.php
@@ -0,0 +1,30 @@
+getEnvironment() == "thetool") {
+ $table = $this->table("Workorder");
+ $table
+ ->addColumn("metadata", "json", [
+ 'null' => true,
+ 'default' => null,
+ 'after' => 'cableType'
+ ])
+ ->update();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $this->table("Workorder")
+ ->removeColumn("metadata")
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php b/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php
new file mode 100644
index 000000000..0b1933697
--- /dev/null
+++ b/db/migrations/20260126120000_warehousearticle_rename_revenueaccount_to_vatgroupid.php
@@ -0,0 +1,33 @@
+getEnvironment() == "thetool") {
+ $this->execute("ALTER TABLE WarehouseArticle CHANGE revenueAccount vatgroup_id INT(11) NOT NULL DEFAULT 2");
+
+ $this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE
+ WHEN vatgroup_id = 0 THEN 2
+ WHEN vatgroup_id = 1 THEN 3
+ ELSE vatgroup_id
+ END");
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+ $this->execute("UPDATE WarehouseArticle SET vatgroup_id = CASE
+ WHEN vatgroup_id = 2 THEN 0
+ WHEN vatgroup_id = 3 THEN 1
+ ELSE vatgroup_id
+ END");
+
+ $this->execute("ALTER TABLE WarehouseArticle CHANGE vatgroup_id revenueAccount INT(11) NOT NULL DEFAULT 0");
+ }
+ }
+}
diff --git a/db/migrations/20260126130000_manualinvoice_column_cleanup.php b/db/migrations/20260126130000_manualinvoice_column_cleanup.php
new file mode 100644
index 000000000..4feef0ec7
--- /dev/null
+++ b/db/migrations/20260126130000_manualinvoice_column_cleanup.php
@@ -0,0 +1,58 @@
+getEnvironment() == "thetool") {
+ $position = $this->table("ManualInvoiceposition");
+ if ($position->hasColumn("billing_id")) {
+ $position->removeColumn("billing_id")->save();
+ }
+ if ($position->hasColumn("contract_id")) {
+ $position->removeColumn("contract_id")->save();
+ }
+ // ManualInvoiceposition: Rename columns
+ $this->execute("ALTER TABLE ManualInvoiceposition CHANGE product_id warehousearticle_id INT(11) NOT NULL");
+ $this->execute("ALTER TABLE ManualInvoiceposition CHANGE product_name warehousearticle_name VARCHAR(255) NOT NULL");
+
+ $invoice = $this->table("ManualInvoice");
+ if ($invoice->hasColumn("billing_delivery")) {
+ $invoice->removeColumn("billing_delivery")->save();
+ }
+
+ $this->execute("ALTER TABLE ManualInvoice CHANGE leistungszeitraum performance_period VARCHAR(255) NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE einleitender_text introductory_text TEXT NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE externe_referenz external_reference VARCHAR(255) NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE gesamtrabatt total_discount DECIMAL(6,2) NOT NULL DEFAULT 0.00");
+ }
+ }
+
+ public function down(): void
+ {
+ if($this->getEnvironment() == "thetool") {
+ $this->execute("ALTER TABLE ManualInvoice CHANGE performance_period leistungszeitraum VARCHAR(255) NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE introductory_text einleitender_text TEXT NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE external_reference externe_referenz VARCHAR(255) NULL");
+ $this->execute("ALTER TABLE ManualInvoice CHANGE total_discount gesamtrabatt DECIMAL(6,2) NOT NULL DEFAULT 0.00");
+
+ $invoice = $this->table("ManualInvoice");
+ $invoice->addColumn("billing_delivery", "enum", [
+ "null" => false,
+ "values" => ["email", "paper"],
+ "after" => "billing_type"
+ ])->save();
+
+ $this->execute("ALTER TABLE ManualInvoiceposition CHANGE warehousearticle_id product_id INT(11) NOT NULL");
+ $this->execute("ALTER TABLE ManualInvoiceposition CHANGE warehousearticle_name product_name VARCHAR(255) NOT NULL");
+
+ $position = $this->table("ManualInvoiceposition");
+ $position->addColumn("billing_id", "integer", ["null" => true, "after" => "position_group"]);
+ $position->addColumn("contract_id", "integer", ["null" => false, "after" => "billing_id"]);
+ $position->save();
+ }
+ }
+}
diff --git a/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php b/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php
new file mode 100644
index 000000000..c896f981a
--- /dev/null
+++ b/db/migrations/20260127120000_add_tiefbau_sees_normal_docs.php
@@ -0,0 +1,32 @@
+getEnvironment() == "thetool") {
+ $table = $this->table('WorkorderTenantConfig');
+
+ $table->addColumn('tiefbauSeesNormalDocs', 'boolean', [
+ 'default' => false,
+ 'null' => false,
+ 'after' => 'showTechnicalData',
+ 'comment' => 'Allow civil engineering status to see and use normal documentation steps'
+ ]);
+
+ $table->update();
+ }
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() == "thetool") {
+ $this->table('WorkorderTenantConfig')
+ ->removeColumn('tiefbauSeesNormalDocs')
+ ->save();
+ }
+ }
+}
diff --git a/db/migrations/20260128074700_add_auto_complete_filter.php b/db/migrations/20260128074700_add_auto_complete_filter.php
new file mode 100644
index 000000000..cb95ef5d5
--- /dev/null
+++ b/db/migrations/20260128074700_add_auto_complete_filter.php
@@ -0,0 +1,36 @@
+getEnvironment() !== 'thetool') {
+ return;
+ }
+
+ $workorderTenantConfigTable = $this->table('WorkorderTenantConfig');
+ if (!$workorderTenantConfigTable->hasColumn('autoCompleteFilter'))
+ $workorderTenantConfigTable->addColumn('autoCompleteFilter', 'text', [
+ 'null' => true,
+ 'default' => null,
+ 'limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG,
+ 'after' => 'workorderActiveFilters',
+ ])
+ ->save();
+ }
+
+ public function down(): void
+ {
+ if ($this->getEnvironment() !== 'thetool') {
+ return;
+ }
+
+ $workorderTenantConfigTable = $this->table('WorkorderTenantConfig');
+ if ($workorderTenantConfigTable->hasColumn('autoCompleteFilter'))
+ $workorderTenantConfigTable->removeColumn('autoCompleteFilter')->save();
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 2748ee516..c81fccc3f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,8 +32,6 @@ services:
image: adminer
ports:
- "8088:8080"
- volumes:
- - ./docker/adminer/php.ini:/etc/php/7.4/cli/conf.d/php.local.ini
phpmyadmin:
image: phpmyadmin
@@ -41,11 +39,30 @@ services:
- "8081:80"
environment:
- PMA_HOST=db
- - PMA_UPLOAD_LIMIT=1G
- - UPLOAD_LIMIT=1G
- MYSQL_ROOT_PASSWORD=junghan5
depends_on:
- db
+ db-downloader:
+ build:
+ context: ./docker/db-downloader
+ dockerfile: Dockerfile
+ ports:
+ - "8082:8082"
+ # volumes:
+ # - ./docker/db-downloader/ssh-keys:/app/ssh-keys:ro
+ environment:
+ - SCP_HOST=thetool-dbbackup.xinon.at
+ - SCP_PORT=22
+ - SCP_USERNAME=xinon
+ - SCP_DEFAULT_PATH=/opt/backup/mysql
+ - DB_HOST=db
+ - DB_PORT=3306
+ - DB_USER=root
+ - DB_PASSWORD=junghan5
+ - DB_AVAILABLE=thetool,addressdb
+ depends_on:
+ - db
+
volumes:
vendor:
diff --git a/docker/db-downloader/Dockerfile b/docker/db-downloader/Dockerfile
new file mode 100644
index 000000000..760d57da8
--- /dev/null
+++ b/docker/db-downloader/Dockerfile
@@ -0,0 +1,25 @@
+FROM python:3.12-slim-bookworm
+
+RUN apt-get update && apt-get install -y \
+ mariadb-client \
+ openssh-client \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY app.py .
+COPY templates/ ./templates/
+COPY static/ ./static/
+
+RUN mkdir -p /app/downloads /app/ssh-keys
+
+ENV FLASK_APP=app:app
+ENV FLASK_ENV=production
+ENV PYTHONUNBUFFERED=1
+
+EXPOSE 8082
+
+CMD ["gunicorn", "--bind", "0.0.0.0:8082", "--workers", "2", "--threads", "4", "app:app"]
diff --git a/docker/db-downloader/app.py b/docker/db-downloader/app.py
new file mode 100644
index 000000000..d9988ee74
--- /dev/null
+++ b/docker/db-downloader/app.py
@@ -0,0 +1,444 @@
+import os
+import stat
+import uuid
+import gzip
+import struct
+import subprocess
+import threading
+import time
+from datetime import datetime
+from flask import Flask, render_template, request, jsonify, session
+import paramiko
+
+
+# =============================================================================
+# Configuration
+# =============================================================================
+class Config:
+ SCP_HOST = os.getenv('SCP_HOST', 'localhost')
+ SCP_PORT = int(os.getenv('SCP_PORT', 22))
+ SCP_USERNAME = os.getenv('SCP_USERNAME', 'root')
+ SCP_DEFAULT_PATH = os.getenv('SCP_DEFAULT_PATH', '/backups')
+
+ DB_HOST = os.getenv('DB_HOST', 'db')
+ DB_PORT = int(os.getenv('DB_PORT', 3306))
+ DB_USER = os.getenv('DB_USER', 'root')
+ DB_PASSWORD = os.getenv('DB_PASSWORD', '')
+ DB_AVAILABLE = os.getenv('DB_AVAILABLE', 'thetool,addressdb').split(',')
+
+ DOWNLOAD_PATH = '/app/downloads'
+ SSH_KEYS_PATH = '/app/ssh-keys'
+ SECRET_KEY = os.getenv('SECRET_KEY', os.urandom(24).hex())
+
+
+# =============================================================================
+# SFTP Client
+# =============================================================================
+class SFTPClient:
+ def __init__(self, host, port, username):
+ self.host = host
+ self.port = port
+ self.username = username
+ self.client = None
+ self.sftp = None
+
+ def connect_password(self, password):
+ self.client = paramiko.SSHClient()
+ self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ self.client.connect(
+ hostname=self.host, port=self.port, username=self.username,
+ password=password, look_for_keys=False, allow_agent=False
+ )
+ self.sftp = self.client.open_sftp()
+
+ def connect_key(self, key_path, passphrase=None):
+ self.client = paramiko.SSHClient()
+ self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ self.client.connect(
+ hostname=self.host, port=self.port, username=self.username,
+ key_filename=key_path, passphrase=passphrase,
+ look_for_keys=False, allow_agent=False
+ )
+ self.sftp = self.client.open_sftp()
+
+ def list_directory(self, path):
+ entries = []
+ for entry in self.sftp.listdir_attr(path):
+ is_dir = stat.S_ISDIR(entry.st_mode)
+ entries.append({
+ 'name': entry.filename,
+ 'size': entry.st_size,
+ 'size_human': self._human_size(entry.st_size),
+ 'mtime': entry.st_mtime,
+ 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
+ 'is_dir': is_dir,
+ 'is_sql': entry.filename.endswith(('.sql', '.sql.gz')),
+ 'path': os.path.join(path, entry.filename)
+ })
+ return sorted(entries, key=lambda x: (not x['is_dir'], -x['mtime']))
+
+ def get_file_info(self, path):
+ entry = self.sftp.stat(path)
+ return {
+ 'name': os.path.basename(path),
+ 'size': entry.st_size,
+ 'size_human': self._human_size(entry.st_size),
+ 'mtime': entry.st_mtime,
+ 'mtime_human': datetime.fromtimestamp(entry.st_mtime).strftime('%Y-%m-%d %H:%M'),
+ 'path': path
+ }
+
+ def download_file(self, remote_path, local_path, callback=None):
+ self.sftp.get(remote_path, local_path, callback=callback)
+
+ def close(self):
+ if self.sftp:
+ self.sftp.close()
+ if self.client:
+ self.client.close()
+
+ @staticmethod
+ def _human_size(size):
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
+ if size < 1024:
+ return f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} PB"
+
+
+# =============================================================================
+# Database Restore
+# =============================================================================
+class DatabaseRestore:
+ def __init__(self):
+ self.host = Config.DB_HOST
+ self.port = Config.DB_PORT
+ self.user = Config.DB_USER
+ self.password = Config.DB_PASSWORD
+ self.available_dbs = Config.DB_AVAILABLE
+ self.cancelled = False
+
+ def cancel(self):
+ self.cancelled = True
+
+ @staticmethod
+ def get_gzip_uncompressed_size(filepath):
+ with open(filepath, 'rb') as f:
+ f.seek(-4, 2)
+ return struct.unpack(' 0 else 0, 'downloaded': transferred, 'total': total})
+
+ client.download_file(remote_file, local_file, callback=download_progress)
+ client.close()
+
+ if jobs[job_id].get('cancelled'):
+ jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
+ if os.path.exists(local_file):
+ os.remove(local_file)
+ return
+
+ jobs[job_id].update({'progress': 45, 'message': 'Download complete. Preparing restore...', 'status': 'restoring'})
+ jobs[job_id]['progress'] = 50
+ jobs[job_id]['message'] = f'Clearing database {target_db}...'
+
+ restorer = DatabaseRestore()
+ restorers[job_id] = restorer
+
+ uncompressed_size = restorer.get_gzip_uncompressed_size(local_file) if local_file.endswith('.gz') else os.path.getsize(local_file)
+
+ def restore_progress(bytes_processed):
+ if jobs[job_id].get('cancelled'):
+ restorer.cancel()
+ pct = 50 + min(45, int((bytes_processed / uncompressed_size) * 45)) if uncompressed_size > 0 else 50
+ jobs[job_id].update({'progress': pct, 'message': f'Restoring to {target_db}... ({bytes_processed // (1024*1024)} MB / {uncompressed_size // (1024*1024)} MB)'})
+
+ result = restorer.restore_from_file(local_file, target_db, progress_callback=restore_progress)
+
+ if os.path.exists(local_file):
+ os.remove(local_file)
+
+ jobs[job_id].update({
+ 'status': 'completed', 'progress': 100,
+ 'message': f'Restore complete! Dropped {result["tables_dropped"]} tables and imported {result["file"]}',
+ 'completed_at': time.time(), 'duration': time.time() - jobs[job_id]['started_at']
+ })
+
+ except Exception as e:
+ error_msg = str(e)
+ if 'cancelled' in error_msg.lower():
+ jobs[job_id].update({'status': 'cancelled', 'message': 'Restore cancelled by user'})
+ else:
+ jobs[job_id].update({'status': 'error', 'error': error_msg, 'message': f'Error: {error_msg}'})
+ if os.path.exists(local_file):
+ os.remove(local_file)
+ finally:
+ restorers.pop(job_id, None)
+
+
+@app.route('/api/status/')
+def status(job_id):
+ if job_id not in jobs:
+ return jsonify({'success': False, 'error': 'Job not found'}), 404
+ job = jobs[job_id].copy()
+ job['success'] = True
+ if 'started_at' in job:
+ elapsed = (job.get('completed_at') or time.time()) - job['started_at']
+ job['elapsed'] = f'{int(elapsed // 60)}m {int(elapsed % 60)}s'
+ return jsonify(job)
+
+
+@app.route('/api/jobs', methods=['GET'])
+def list_jobs():
+ return jsonify({'success': True, 'jobs': dict(jobs)})
+
+
+@app.route('/api/cancel/', methods=['POST'])
+def cancel(job_id):
+ if job_id not in jobs:
+ return jsonify({'success': False, 'error': 'Job not found'}), 404
+ if jobs[job_id]['status'] in ('completed', 'error', 'cancelled'):
+ return jsonify({'success': False, 'error': 'Job already finished'}), 400
+ jobs[job_id]['cancelled'] = True
+ if job_id in restorers:
+ restorers[job_id].cancel()
+ return jsonify({'success': True, 'message': 'Cancel signal sent'})
+
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=8082, debug=True)
diff --git a/docker/db-downloader/requirements.txt b/docker/db-downloader/requirements.txt
new file mode 100644
index 000000000..973a9f474
--- /dev/null
+++ b/docker/db-downloader/requirements.txt
@@ -0,0 +1,5 @@
+flask==3.0.0
+gunicorn==21.2.0
+paramiko==3.4.0
+mysql-connector-python==8.2.0
+python-dotenv==1.0.0
diff --git a/docker/db-downloader/static/app.js b/docker/db-downloader/static/app.js
new file mode 100644
index 000000000..c949e1164
--- /dev/null
+++ b/docker/db-downloader/static/app.js
@@ -0,0 +1,457 @@
+// DB Restore Tool - Frontend JavaScript
+
+let currentPath = '';
+let selectedFile = null;
+let isConnected = false;
+let currentJobId = null;
+let pollInterval = null;
+
+// Initialize
+document.addEventListener('DOMContentLoaded', function() {
+ loadAvailableKeys();
+ setupEventListeners();
+});
+
+function setupEventListeners() {
+ // Auth type toggle
+ document.getElementById('auth-type').addEventListener('change', function() {
+ const passwordAuth = document.getElementById('password-auth');
+ const keyAuth = document.getElementById('key-auth');
+ if (this.value === 'password') {
+ passwordAuth.classList.remove('hidden');
+ keyAuth.classList.add('hidden');
+ } else {
+ passwordAuth.classList.add('hidden');
+ keyAuth.classList.remove('hidden');
+ }
+ });
+
+ // Connect form
+ document.getElementById('connect-form').addEventListener('submit', function(e) {
+ e.preventDefault();
+ connect();
+ });
+
+ // Disconnect button
+ document.getElementById('disconnect-btn').addEventListener('click', disconnect);
+
+ // Restore button
+ document.getElementById('restore-btn').addEventListener('click', startRestore);
+
+ // Cancel button
+ document.getElementById('cancel-btn').addEventListener('click', cancelRestore);
+}
+
+async function loadAvailableKeys() {
+ try {
+ const response = await fetch('/api/keys');
+ const data = await response.json();
+ const select = document.getElementById('key-file');
+ select.innerHTML = '';
+ data.keys.forEach(key => {
+ const option = document.createElement('option');
+ option.value = key;
+ option.textContent = key;
+ select.appendChild(option);
+ });
+ } catch (error) {
+ console.error('Failed to load keys:', error);
+ }
+}
+
+async function connect() {
+ const authType = document.getElementById('auth-type').value;
+ const password = document.getElementById('password').value;
+ const keyFile = document.getElementById('key-file').value;
+ const keyPassphrase = document.getElementById('key-passphrase').value;
+
+ const btn = document.getElementById('connect-btn');
+ btn.disabled = true;
+ btn.textContent = 'Connecting...';
+
+ try {
+ const response = await fetch('/api/connect', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ auth_type: authType,
+ password: password,
+ key_file: keyFile,
+ key_passphrase: keyPassphrase
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ isConnected = true;
+ currentPath = data.path;
+ showStatus('Connected to ' + data.host, 'success');
+ renderFiles(data.files, data.path);
+ updateBreadcrumb(data.path);
+
+ // Toggle buttons
+ btn.classList.add('hidden');
+ document.getElementById('disconnect-btn').classList.remove('hidden');
+
+ // Clear password field for security
+ document.getElementById('password').value = '';
+ } else {
+ showStatus('Connection failed: ' + data.error, 'error');
+ }
+ } catch (error) {
+ showStatus('Connection error: ' + error.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Connect';
+ }
+}
+
+async function disconnect() {
+ try {
+ await fetch('/api/disconnect', { method: 'POST' });
+ } catch (e) {}
+
+ isConnected = false;
+ selectedFile = null;
+ currentPath = '';
+
+ // Reset UI
+ document.getElementById('connect-btn').classList.remove('hidden');
+ document.getElementById('disconnect-btn').classList.add('hidden');
+ document.getElementById('file-browser').innerHTML = `
+
+
+ Connect to browse remote files
+
+ `;
+ document.getElementById('breadcrumb').innerHTML = 'Not connected';
+ document.getElementById('selected-file-info').innerHTML = 'No file selected ';
+ document.getElementById('restore-btn').disabled = true;
+
+ hideStatus();
+}
+
+async function browse(path) {
+ if (!isConnected) return;
+
+ try {
+ const response = await fetch('/api/browse', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: path })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ currentPath = data.path;
+ renderFiles(data.files, data.path);
+ updateBreadcrumb(data.path);
+ } else {
+ showStatus('Browse failed: ' + data.error, 'error');
+ }
+ } catch (error) {
+ showStatus('Browse error: ' + error.message, 'error');
+ }
+}
+
+function renderFiles(files, path) {
+ const container = document.getElementById('file-browser');
+
+ if (files.length === 0) {
+ container.innerHTML = 'Empty directory ';
+ return;
+ }
+
+ let html = '';
+
+ // Parent directory link
+ if (path !== '/') {
+ const parentPath = path.split('/').slice(0, -1).join('/') || '/';
+ html += `
+
+ `;
+ }
+
+ files.forEach(file => {
+ const isSelected = selectedFile && selectedFile.path === file.path;
+ const selectedClass = isSelected ? 'selected' : '';
+
+ if (file.is_dir) {
+ html += `
+
+ `;
+ } else if (file.is_sql) {
+ html += `
+
+
+ ${file.name}
+ ${file.size_human}
+ ${file.mtime_human}
+
+ `;
+ } else {
+ html += `
+
+
+ ${file.name}
+ ${file.size_human}
+
+ `;
+ }
+ });
+
+ html += ' ';
+ container.innerHTML = html;
+}
+
+function selectFile(file) {
+ selectedFile = file;
+ document.getElementById('selected-file-info').innerHTML = `
+ ${file.name}
+ ${file.size_human} - ${file.mtime_human}
+ `;
+ document.getElementById('restore-btn').disabled = false;
+
+ // Auto-detect target database from filename
+ const filename = file.name.toLowerCase();
+ const targetDbSelect = document.getElementById('target-db');
+ const availableDbs = Array.from(targetDbSelect.options).map(o => o.value);
+
+ for (const db of availableDbs) {
+ if (filename.includes(db.toLowerCase())) {
+ targetDbSelect.value = db;
+ break;
+ }
+ }
+
+ // Re-render to show selection
+ browse(currentPath);
+}
+
+function updateBreadcrumb(path) {
+ const parts = path.split('/').filter(p => p);
+ let html = `/`;
+
+ let currentPathBuild = '';
+ parts.forEach((part, index) => {
+ currentPathBuild += '/' + part;
+ const isLast = index === parts.length - 1;
+ html += `
+ /
+ ${part}
+ `;
+ });
+
+ document.getElementById('breadcrumb').innerHTML = html;
+}
+
+async function startRestore() {
+ if (!selectedFile) return;
+
+ const targetDb = document.getElementById('target-db').value;
+
+ if (!confirm(`Are you sure you want to restore ${selectedFile.name} to database "${targetDb}"?\n\nThis will DROP ALL TABLES in the database!`)) {
+ return;
+ }
+
+ const btn = document.getElementById('restore-btn');
+ btn.disabled = true;
+ btn.textContent = 'Starting...';
+
+ try {
+ const response = await fetch('/api/restore', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ file: selectedFile.path,
+ database: targetDb
+ })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ currentJobId = data.job_id;
+ showProgressPanel();
+ startPolling();
+ } else {
+ showStatus('Restore failed: ' + data.error, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Start Restore';
+ }
+ } catch (error) {
+ showStatus('Restore error: ' + error.message, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Start Restore';
+ }
+}
+
+function showProgressPanel() {
+ document.getElementById('progress-panel').classList.remove('hidden');
+ document.getElementById('progress-bar').style.width = '0%';
+ document.getElementById('progress-bar').classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
+ document.getElementById('progress-bar').classList.add('bg-blue-600');
+ document.getElementById('progress-percent').textContent = '0%';
+ document.getElementById('progress-status').textContent = 'Starting...';
+ document.getElementById('progress-message').textContent = 'Initializing...';
+ document.getElementById('cancel-btn').classList.remove('hidden');
+ document.getElementById('cancel-btn').disabled = false;
+ document.getElementById('cancel-btn').textContent = 'Cancel Restore';
+}
+
+function startPolling() {
+ if (pollInterval) clearInterval(pollInterval);
+
+ pollInterval = setInterval(async () => {
+ try {
+ const response = await fetch(`/api/status/${currentJobId}`);
+ const data = await response.json();
+
+ if (data.success) {
+ updateProgress(data);
+
+ if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
+ stopPolling();
+ document.getElementById('restore-btn').disabled = false;
+ document.getElementById('restore-btn').textContent = 'Start Restore';
+
+ if (data.status === 'completed') {
+ showStatus('Restore completed successfully!', 'success');
+ } else if (data.status === 'cancelled') {
+ showStatus('Restore was cancelled', 'error');
+ } else {
+ showStatus('Restore failed: ' + data.error, 'error');
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Polling error:', error);
+ }
+ }, 250);
+}
+
+function stopPolling() {
+ if (pollInterval) {
+ clearInterval(pollInterval);
+ pollInterval = null;
+ }
+}
+
+async function cancelRestore() {
+ if (!currentJobId) return;
+
+ if (!confirm('Are you sure you want to cancel the restore?')) {
+ return;
+ }
+
+ const btn = document.getElementById('cancel-btn');
+ btn.disabled = true;
+ btn.textContent = 'Cancelling...';
+
+ try {
+ const response = await fetch(`/api/cancel/${currentJobId}`, {
+ method: 'POST'
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ showStatus('Cancellation requested...', 'error');
+ } else {
+ showStatus('Cancel failed: ' + data.error, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Cancel Restore';
+ }
+ } catch (error) {
+ showStatus('Cancel error: ' + error.message, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Cancel Restore';
+ }
+}
+
+function updateProgress(data) {
+ const bar = document.getElementById('progress-bar');
+ const percent = document.getElementById('progress-percent');
+ const status = document.getElementById('progress-status');
+ const message = document.getElementById('progress-message');
+ const elapsed = document.getElementById('progress-elapsed');
+ const cancelBtn = document.getElementById('cancel-btn');
+
+ bar.style.width = data.progress + '%';
+ percent.textContent = data.progress + '%';
+
+ // Update status label
+ const statusLabels = {
+ 'starting': 'Starting',
+ 'downloading': 'Downloading',
+ 'restoring': 'Restoring',
+ 'completed': 'Completed',
+ 'error': 'Error',
+ 'cancelled': 'Cancelled'
+ };
+ status.textContent = statusLabels[data.status] || data.status;
+
+ // Update color based on status
+ bar.classList.remove('bg-green-600', 'bg-red-600', 'bg-yellow-600');
+ if (data.status === 'completed') {
+ bar.classList.remove('bg-blue-600');
+ bar.classList.add('bg-green-600');
+ } else if (data.status === 'error') {
+ bar.classList.remove('bg-blue-600');
+ bar.classList.add('bg-red-600');
+ } else if (data.status === 'cancelled') {
+ bar.classList.remove('bg-blue-600');
+ bar.classList.add('bg-yellow-600');
+ }
+
+ // Show/hide cancel button based on job status
+ if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') {
+ cancelBtn.classList.add('hidden');
+ } else {
+ cancelBtn.classList.remove('hidden');
+ cancelBtn.disabled = false;
+ cancelBtn.textContent = 'Cancel Restore';
+ }
+
+ message.textContent = data.message || '';
+ if (data.elapsed) {
+ elapsed.textContent = 'Elapsed: ' + data.elapsed;
+ }
+}
+
+function showStatus(message, type) {
+ const status = document.getElementById('connection-status');
+ status.classList.remove('hidden', 'bg-green-100', 'bg-red-100', 'text-green-800', 'text-red-800');
+
+ if (type === 'success') {
+ status.classList.add('bg-green-100', 'text-green-800');
+ } else if (type === 'error') {
+ status.classList.add('bg-red-100', 'text-red-800');
+ }
+
+ status.textContent = message;
+}
+
+function hideStatus() {
+ document.getElementById('connection-status').classList.add('hidden');
+}
diff --git a/docker/db-downloader/templates/index.html b/docker/db-downloader/templates/index.html
new file mode 100644
index 000000000..2ea9fed98
--- /dev/null
+++ b/docker/db-downloader/templates/index.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+ DB Restore Tool
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connection
+
+
+
+
+
+
+
+
+
+
+
+ Restore
+
+
+
+
+
+
+
+
+
+
+
+ Warning: This will DROP all tables in the selected database before restoring!
+
+
+
+
+
+
+
+
+
+
+
+
+ Remote Browser
+
+
+
+
+ Not connected
+
+
+
+
+
+
+ Connect to browse remote files
+
+
+
+
+
+
+
+
+ Restore Progress
+
+
+
+
+ Initializing...
+ 0%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib/Citycom/OanApiClient.php b/lib/Citycom/OanApiClient.php
index c5f61c546..7d61e91f7 100644
--- a/lib/Citycom/OanApiClient.php
+++ b/lib/Citycom/OanApiClient.php
@@ -219,6 +219,21 @@ class Citycom_OanApiClient {
$url = str_replace("{service_id}", $service_id, $this->baseurl.CITYCOM_OAN_API_EP_UPDATE_SERVICES);
+ $ctx_options = [
+ "http" => [
+ "ignore_errors" => true,
+ "method" => "PUT",
+ "content" => json_encode($data),
+ "header" => [
+ "Accept: application/json",
+ "Content-type: application/json",
+ "Authorization: Bearer ".$this->token,
+ ],
+ ]
+ ];
+
+ $result = $this->runApiRequest($url, $ctx_options);
+ return $result;
}
public function cancelService($service_id, $data) {
@@ -449,7 +464,7 @@ class Citycom_OanApiClient {
$output = file_get_contents($final_url, false, $ctx);
$resp = json_decode($output);
- //var_dump($resp);
+ //var_dump($resp);exit;
if(!is_object($resp) || (property_exists($resp, "success") && !$resp->success)) {
$this->lastError = $output;
return false;
@@ -459,6 +474,8 @@ class Citycom_OanApiClient {
if(is_array($response)) {
$return_data = $response;
+ } elseif(is_object($response) && (!property_exists($response, "data") || !is_array($response->data))) {
+ $return_data = $response;
} elseif(is_object($response) && property_exists($response, "data") && is_array($response->data)) {
$return_data = $response->data;
diff --git a/lib/Citycom/OanApiHelper.php b/lib/Citycom/OanApiHelper.php
index ccd3bacc7..969b8a27c 100644
--- a/lib/Citycom/OanApiHelper.php
+++ b/lib/Citycom/OanApiHelper.php
@@ -94,7 +94,7 @@ class Citycom_OanApiHelper {
$execution_date = date("Y-m-d");
}
-
+ $ctag_range_search = false;
if(array_key_exists("ctag_range_search", $data) && $data["ctag_range_search"]) {
$ctag_range_search = $data["ctag_range_search"];
@@ -116,21 +116,21 @@ class Citycom_OanApiHelper {
// order all services and save ctags
$cc_service_types = $this->api->getServiceTypes();
- $this->log->debug(print_r($want_services, true));
+ $this->log->debug(__METHOD__.": Want services: ".print_r($want_services, true));
$allowed_service_types = array_merge(CITYCOM_OAN_API_SERVICES_FOR_ORDER, CITYCOM_OAN_API_SERVICES_FOR_RESERVATION);
// check if we have these services already
foreach($cc_service_types as $stype) {
if(!in_array($stype->name, $allowed_service_types)) continue;
- $ctag_service_type = array_flip($allowed_service_types)[$stype->name];
+ $ctag_service_type = (array_flip($allowed_service_types))[$stype->name];
if(PreorderCtag::getFirstActive(["preorder_id" => $preorder->id, "service_type" => $ctag_service_type])) {
// service was ordered already, remove from want_services
unset($want_services[$ctag_service_type]);
}
}
- $this->log->debug(print_r($want_services, true));
+ $this->log->debug(__METHOD__.": Want services after filtering: ".print_r($want_services, true));
$new_services = [];
@@ -140,26 +140,32 @@ class Citycom_OanApiHelper {
list($ctags, $mgmt_ctag) = $preorder->getNextFreeCtags();
}
- $this->log->debug(print_r($ctags, true));
+ //var_dump($ctags);
+ //var_dump($mgmt_ctag);
+
+ $this->log->debug(__METHOD__.": ctags: ".print_r($ctags, true));
+ $this->log->debug(__METHOD__.": mgmt ctag: ".print_r($mgmt_ctag, true));
if(!is_array($ctags)) {
$this->log->error(__METHOD__.": No Free Ctags (Preorder ".$preorder->id.")");
return false;
}
- if(count($ctags) < count($want_services)) {
+ $ctag_count = count($ctags);
+ if($mgmt_ctag) $ctag_count++;
+ if($ctag_count < count($want_services)) {
$this->log->error(__METHOD__.": Not enough New Free CTags for Preorder ".$preorder->id);
return false;
}
-
$preorder_ctag_data = [
"preorder_id" => $preorder->id,
"network" => "citycom-oan",
"stag" => $preorder->adb_hausnummer->vlan_stag,
];
+
$service_count = 0;
foreach($cc_service_types as $stype) {
// was this service type requested
@@ -169,7 +175,7 @@ class Citycom_OanApiHelper {
if($mgmt_ctag && $stype->name == $allowed_service_types["mgmt"]) {
$ctag = $mgmt_ctag;
} else {
- $ctag = $ctags[$service_count];
+ $ctag = array_shift($ctags);
}
$ctag_service_type = array_flip($allowed_service_types)[$stype->name];
if(!$ctag_service_type) {
@@ -185,6 +191,7 @@ class Citycom_OanApiHelper {
"ctag" => $ctag,
];
+ //echo "Creating Service ".$stype->name." on sublocation $sublocation_id with product_id $product_id and ctag $ctag\n";
$this->log->info(__METHOD__.": Creating Service ".$stype->name." on sublocation $sublocation_id with product_id $product_id and ctag $ctag");
//continue;
@@ -247,11 +254,61 @@ class Citycom_OanApiHelper {
}
-
-
return true;
}
+ /**
+ * Updates service values if nesseccary
+ *
+ * @param $service_ext_num
+ * @param $data
+ * @return bool
+ */
+ public function updateService($service_ext_id, $data) {
+ // get service and compare data
+ $services = $this->api->getServices();
+ if(!$services) {
+ $this->log->error(__METHOD__.": Error getting services.");
+ return false;
+ }
+
+ $service = false;
+ foreach($services as $cc_service) {
+ if($cc_service->id == $service_ext_id) {
+ $service = $cc_service;
+ break;
+ }
+ }
+
+ if(!$service) {
+ $this->log->error(__METHOD__.": Service not available.");
+ return false;
+ }
+
+ $service_data = [];
+
+ // update service if nesseccary
+ if(array_key_exists("product_name", $data) && $data["product_name"] && $service->product->name != $data["product_name"]) {
+ $product_data["up"] = $data["up"];
+ $product_data["down"] = $data["down"];
+ $product_data["name"] = $data["product_name"];
+ $product_id = $this->findOrCreateProduct($product_data);
+ if(!$product_id) {
+ $this->log->error(__METHOD__.": Cannot find or create product ".$product_data["name"]);
+ return false;
+ }
+
+ $service_data["product"] = $product_id;
+ }
+
+ if(!count($service_data)) return true;
+ $result = $this->api->updateService($service->id, $service_data);
+
+ if($result) return true;
+ return false;
+
+ }
+
public static function citycomIdToHausnummerExtref($id) {
diff --git a/lib/GenieACS/GenieACS.php b/lib/GenieACS/GenieACS.php
index 540026e90..6905065b9 100644
--- a/lib/GenieACS/GenieACS.php
+++ b/lib/GenieACS/GenieACS.php
@@ -106,6 +106,24 @@ class GenieACS {
return $this->_request('GET', '/api/devices');
}
+ public function getDeviceByMac($mac) {
+ $mac = strtolower(preg_replace('/[^A-Fa-f0-9]/', '', $mac));
+ $mac = implode(':', str_split($mac, 2));
+
+ $paths = [
+ 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.MACAddress',
+ 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.2.MACAddress',
+ ];
+
+ foreach ($paths as $path) {
+ $filter = urlencode($path . ' = "' . $mac . '"');
+ $result = $this->_request('GET', '/api/devices/?filter=' . $filter . '&limit=1');
+ if ($result && is_array($result) && count($result) > 0) return $result[0];
+ }
+
+ return null;
+ }
+
public function getDevice($deviceId) {
return $this->_request('GET', '/api/devices/' . rawurlencode($deviceId));
}
diff --git a/lib/Rimoapi/Rimoapi.php b/lib/Rimoapi/Rimoapi.php
index f50c1fe2c..a371a9ffd 100644
--- a/lib/Rimoapi/Rimoapi.php
+++ b/lib/Rimoapi/Rimoapi.php
@@ -395,12 +395,18 @@ class Rimoapi {
return false;
}
+ $filename = false;
+ $filetype = false;
+
foreach($items->item as $item) {
if(!$item->name) continue;
if(!preg_match('/_AHA.pdf$/i', $item->name)) continue;
$filename = $item->name;
+ if(property_exists($item, "fileType") && is_object($item->fileType) && property_exists($item->fileType, "name")) {
+ $filetype = $item->fileType->name;
+ }
}
if(!$filename) {
@@ -413,6 +419,9 @@ class Rimoapi {
$params['apiKey'] = $apikey;
$params['objectId'] = $rimo_id;
$params['fileNames'] = $filename;
+ if($filetype) {
+ $params["fileTypeNames"] = $filetype;
+ }
$ctx_opts = [
'http' => [
@@ -553,13 +562,13 @@ class Rimoapi {
return $resp_data;
}
- public static function addRemark($object_id, $text) {
+ public static function addRemark($apikey, $object_id, $text) {
if(!$object_id || !$text) return false;
$log = mfLoghandler::singleton();
$params = [];
- $params['apiKey'] = RIMO_API_JSON_APIKEY;
+ $params['apiKey'] = $apikey;
$params['objectId'] = $object_id;
$params['text'] = $text;
diff --git a/lib/SNOPP/SNOPP.php b/lib/SNOPP/SNOPP.php
deleted file mode 100644
index d995ea3e5..000000000
--- a/lib/SNOPP/SNOPP.php
+++ /dev/null
@@ -1,36 +0,0 @@
- [
- 'method' => 'GET',
- 'header' => "X-Api-Key: $SNOPP_API_KEY\r\n",
- ]
- ];
-
- $snopp_output = file_get_contents("$SNOPP_API_URL/ticket/find?provider_id=all&status=open", false, stream_context_create($ctx_opts));
- $ticket_obj = json_decode($snopp_output);
- $tickets = $ticket_obj->result->tickets;
-
- return $tickets;
- }
-
-}
-?>
diff --git a/lib/Snoppapi/Snoppapi.php b/lib/Snoppapi/Snoppapi.php
new file mode 100644
index 000000000..2b7aedaca
--- /dev/null
+++ b/lib/Snoppapi/Snoppapi.php
@@ -0,0 +1,111 @@
+baseurl = $baseurl;
+ $this->apikey = $apikey;
+
+ $this->log = mfLoghandler::singleton();
+ }
+
+
+ public function searchAddress(Array $address_data) {
+ $street = trim($address_data['street']);
+ $zip = trim($address_data['zip']);
+ $city = trim($address_data['city']);
+
+ $ctx_opts = [
+ 'http' => [
+ 'method' => 'GET',
+ 'ignore_errors' => true,
+ 'header' => [
+ "Accept: application/json",
+ "X-Api-Key: {$this->apikey}",
+ ]
+ ]
+ ];
+
+ $getHomesEp = $this->baseurl.SNOPP_API_EP_GET_HOMES;
+ //$url = $getHomesEp."?".$qs;
+ $url = $getHomesEp;
+
+ $ctx = stream_context_create($ctx_opts);
+ $this->log->debug(__METHOD__.": Getting SNOPP homes: $url");
+ $response = file_get_contents($url, false, $ctx);
+ //var_dump($response);exit;
+ if($response === false) {
+ $this->log->error("Fehler beim Homes abfragen in SNOPP");
+ return false;
+ }
+
+ $resp_data = json_decode($response);
+ $homes = $resp_data->result->homes;
+
+ $results = [];
+
+ foreach($homes as $home) {
+ if(trim($home->street) == $street
+ && trim($home->zip) == $zip
+ && trim($home->city) == $city
+ ) {
+ $results[] = $home;
+ }
+ }
+
+ return $results;
+ }
+
+ public function submitOrder(Array $order_data) {
+ $data = [];
+ foreach(["oan_id", "execution_date", "product_id", "name", "street", "zip", "city"] as $field) {
+ if(!array_key_exists($field, $order_data) || !$order_data[$field]) {
+ $this->log->error(__METHOD__.": Mandatory field '$field' missing");
+ return false;
+ }
+ $data[$field] = $order_data[$field];
+ }
+
+ foreach(["phone", "mobile", "email"] as $field) {
+ if(array_key_exists($field, $order_data) && $order_data[$field]) {
+ $data[$field] = $order_data[$field];
+ }
+ }
+
+ $ctx_opts = [
+ 'http' => [
+ 'method' => 'POST',
+ 'content' => json_encode($data),
+ 'ignore_errors' => true,
+ 'header' => [
+ "Accept: application/json",
+ "Content-type: application/json",
+ "X-Api-Key: {$this->apikey}",
+ ]
+ ]
+ ];
+
+ $url = $this->baseurl.SNOPP_API_EP_SUBMIT_ORDER;
+
+ $ctx = stream_context_create($ctx_opts);
+ $this->log->debug(__METHOD__.": Ordering Snopp product: $url\n".print_r($data, true));
+ $response = file_get_contents($url, false, $ctx);
+
+ $this->log->debug(__METHOD__.": ".print_r($response, true));
+
+ if($response === false) {
+ $this->log->error("Fehler beim Bestellen in SNOPP ".print_r($response, true));
+ return false;
+ }
+
+ $resp_data = json_decode($response);
+ return $resp_data;
+ }
+
+}
diff --git a/public/.htaccess b/public/.htaccess
index efdafabbc..7485068b2 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -25,6 +25,32 @@ RewriteCond %{REQUEST_FILENAME} !-l
RewriteRule ^api/(v\d+)/([^/]+)(/.+)$ index.php?action=Api&apiv=$1&apicall=$2&apiparams=$3 [QSA]
+# MobileApp routing: /MobileApp/{module}/{submodule}/{action}
+# Example: /MobileApp/Lager/Inventur/getActiveStocktakes
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-l
+RewriteRule ^MobileApp/([^/]+)/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2&endpoint=$3 [QSA,L]
+
+# /MobileApp/{module}/{submodule} - e.g., /MobileApp/Lager/Inventur
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-l
+RewriteRule ^MobileApp/([^/]+)/([^/]+)/?$ index.php?action=MobileApp&module=$1&submodule=$2 [QSA,L]
+
+# /MobileApp/{module} - e.g., /MobileApp/auth or /MobileApp/Lager
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-l
+RewriteRule ^MobileApp/([^/]+)/?$ index.php?action=MobileApp&module=$1 [QSA,L]
+
+# /MobileApp - Main app
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteCond %{REQUEST_FILENAME} !-l
+RewriteRule ^MobileApp/?$ index.php?action=MobileApp [QSA,L]
+
+
# regular web calls
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css
index 579e4069b..e45b7ba09 100644
--- a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css
+++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.css
@@ -524,6 +524,54 @@
border: 1px solid #c9e6d8;
}
+/* ===== Copy From Section ===== */
+.tt-scope .copy-from-section {
+ background: #f8fafc;
+ border-radius: 8px;
+ padding: 12px 16px;
+ border: 1px dashed var(--tt-border);
+}
+
+.tt-scope .copy-from-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.tt-scope .copy-select {
+ flex: 1;
+ max-width: 350px;
+}
+
+.tt-scope .copy-select select {
+ width: 100%;
+}
+
+.tt-scope .copy-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.tt-scope .copy-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.tt-scope .copy-hint {
+ font-size: 11px;
+ color: var(--tt-muted);
+ margin-top: 6px;
+}
+
+.tt-scope .form-divider {
+ border: none;
+ height: 1px;
+ background: var(--tt-border);
+ margin: 4px 0 0 0;
+}
+
/* ===== Utilities ===== */
.tt-scope .mono { font-family: var(--tt-mono); }
.tt-scope .muted { color: var(--tt-muted); }
diff --git a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js
index ecc100c17..35cadf513 100644
--- a/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js
+++ b/public/js/pages/ADBNetzgebiet/ADBNetzgebiet.js
@@ -124,8 +124,7 @@ const ADBNetzgebiet = {
- {{ cons.name }}
+ target="_blank" class="related-link" v-html="formatConsentName(cons.name)">
+{{ item.related.consent_projects.length - 1 }}
@@ -134,7 +133,16 @@ const ADBNetzgebiet = {
+
+
|
@@ -274,6 +282,25 @@ const ADBNetzgebiet = {
+
+
+
+
+
+ Lade Log...
+
+
+
+ Kein Log vorhanden.
+
+ {{ rimoLogContent }}
+
+
+
+
+
`,
@@ -294,6 +321,16 @@ const ADBNetzgebiet = {
historyItems: [],
historyTitle: 'Verlauf',
expandedIds: {},
+
+ // RIMO Import
+ importStatus: {},
+ showRimoLogModal: false,
+ rimoLogContent: '',
+ rimoLogTitle: '',
+ rimoLogStatus: 'idle',
+ rimoLogInterval: null,
+ statusInterval: null,
+
freigabeLabels: { interest: 'Interest', provision: 'Provision', order: 'Order', reorder: 'Reorder' },
freigabeOptions: [
{ key: 'interest', label: 'Interest' },
@@ -367,9 +404,24 @@ const ADBNetzgebiet = {
filteredNetzgebiete() { if (this.currentPage > this.totalPages) this.currentPage = 1; }
},
- async mounted() { await this.fetchNetzgebiete(); },
+ async mounted() {
+ await this.fetchNetzgebiete();
+ this.fetchImportStatus();
+ this.statusInterval = setInterval(this.fetchImportStatus, 15000); // Poll every 15s
+ },
+
+ beforeDestroy() {
+ clearInterval(this.statusInterval);
+ clearInterval(this.rimoLogInterval);
+ },
methods: {
+ formatConsentName(name) {
+ if (name && name.startsWith('Glasfaserprojekt')) {
+ return name.replace('Glasfaserprojekt', 'Glasfaserprojekt ');
+ }
+ return name;
+ },
debouncedFilter() {
clearTimeout(this.filterDebounce);
this.filterDebounce = setTimeout(() => this.currentPage = 1, 300);
@@ -419,6 +471,25 @@ const ADBNetzgebiet = {
};
this.showEditModal = true;
},
+ async copyNetzgebiet(item) {
+ const n = item.netzgebiet;
+ let options = {};
+ try { options = JSON.parse(n.options || '{}'); } catch {}
+ let freigabeArr = [];
+ try { freigabeArr = JSON.parse(n.freigabe || '[]') || []; } catch {}
+ const freigabeObj = {};
+ ['interest', 'provision', 'order', 'reorder'].forEach(f => freigabeObj[f] = freigabeArr.includes(f));
+ this.editItem = {
+ id: null,
+ name: '',
+ extref: '',
+ source: n.source || '',
+ source_id: '',
+ freigabe: freigabeObj,
+ options: { ...this.defaultOptions, ...options }
+ };
+ this.showEditModal = true;
+ },
async saveNetzgebiet() {
if (!this.editItem?.name) return;
this.isSaving = true;
@@ -451,6 +522,96 @@ const ADBNetzgebiet = {
} catch { window.notify?.('error', 'Verlauf konnte nicht geladen werden.'); }
finally { this.historyLoading = false; }
},
+
+ // RIMO Import Methods
+ async fetchImportStatus() {
+ const rimoIds = this.netzgebiete
+ .filter(item => item.netzgebiet.source && item.netzgebiet.source.startsWith('rimo-'))
+ .map(item => item.netzgebiet.id);
+ if (!rimoIds.length) return;
+ try {
+ const response = await axios.post(window.TT_CONFIG.GET_RIMO_IMPORT_STATUS_URL, { ids: rimoIds });
+ if (response.data.success) {
+ this.importStatus = response.data.data;
+ }
+ } catch (error) {
+ console.error("Could not fetch RIMO import statuses.", error);
+ }
+ },
+ handleRimoImportClick(item) {
+ const status = this.importStatus[item.netzgebiet.id]?.status || 'idle';
+ if (status === 'running') {
+ this.openRimoLogModal(item);
+ } else if (status === 'cooldown') {
+ const remaining = this.importStatus[item.netzgebiet.id]?.remaining || 0;
+ window.notify?.('info', `Bitte warten Sie noch ${Math.ceil(remaining / 60)} Minuten.`);
+ this.openRimoLogModal(item);
+ } else {
+ this.startRimoImport(item);
+ }
+ },
+ getImportButtonTitle(id) {
+ const status = this.importStatus[id]?.status || 'idle';
+ if (status === 'running') return 'Import-Log anzeigen';
+ if (status === 'cooldown') return 'Manueller RIMO-Import (Abkühlphase)';
+ return 'Manuellen RIMO-Import starten';
+ },
+ getImportButtonDisabled(id) {
+ const status = this.importStatus[id]?.status || 'idle';
+ return false; // Never truly disabled, just changes action
+ },
+ getImportButtonIcon(id) {
+ const status = this.importStatus[id]?.status || 'idle';
+ if (status === 'running') return 'fa-spinner fa-spin';
+ if (status === 'cooldown') return 'fa-hourglass-half';
+ return 'fa-rocket';
+ },
+ async startRimoImport(item) {
+ try {
+ const response = await axios.get(window.TT_CONFIG.START_RIMO_IMPORT_URL + '?id=' + item.netzgebiet.id);
+ if (response.data.success) {
+ window.notify?.('success', 'RIMO Import gestartet.');
+ this.importStatus[item.netzgebiet.id] = { status: 'running' };
+ this.openRimoLogModal(item);
+ } else {
+ window.notify?.('error', response.data.message || 'Import konnte nicht gestartet werden.');
+ }
+ } catch (error) {
+ window.notify?.('error', 'Fehler beim Starten des Imports.');
+ console.error(error);
+ }
+ },
+ openRimoLogModal(item) {
+ this.rimoLogTitle = `RIMO Import: ${item.netzgebiet.name}`;
+ this.showRimoLogModal = true;
+ this.fetchRimoLog(item); // initial fetch
+ this.rimoLogInterval = setInterval(() => this.fetchRimoLog(item), 3000);
+ },
+ closeRimoLogModal() {
+ this.showRimoLogModal = false;
+ clearInterval(this.rimoLogInterval);
+ this.rimoLogContent = '';
+ this.rimoLogTitle = '';
+ this.rimoLogStatus = 'idle';
+ },
+ async fetchRimoLog(item) {
+ try {
+ const response = await axios.get(window.TT_CONFIG.GET_RIMO_IMPORT_LOG_URL + '?id=' + item.netzgebiet.id);
+ if (response.data.success) {
+ this.rimoLogContent = response.data.data.log;
+ this.rimoLogStatus = response.data.data.status;
+ // If no longer running, stop polling
+ if (this.rimoLogStatus !== 'running') {
+ clearInterval(this.rimoLogInterval);
+ this.fetchImportStatus(); // refresh overall status
+ }
+ }
+ } catch (error) {
+ console.error('Could not fetch RIMO log.', error);
+ clearInterval(this.rimoLogInterval);
+ }
+ },
+
translateAction(action) { return { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht' }[action] || action; },
translateField(field) {
return { name: 'Name', extref: 'ExtRef', source: 'Quelle', source_id: 'Source ID',
diff --git a/public/js/pages/AssetManagement/AssetManagement.js b/public/js/pages/AssetManagement/AssetManagement.js
index a26bd81d1..64ab588d0 100644
--- a/public/js/pages/AssetManagement/AssetManagement.js
+++ b/public/js/pages/AssetManagement/AssetManagement.js
@@ -454,6 +454,14 @@ Vue.component('asset-management-modal', {
+
@@ -488,9 +496,11 @@ Vue.component('asset-management-modal', {
`,
data(){
return {
+ categoryAutoCompleteUrl: window.TT_CONFIG.BASE_PATH + '/AssetManagement/getCategories',
asset: {
name: '',
description: '',
+ category: '',
assetNumber: '',
location: 'Liftkammer',
serviceDueDate: null,
diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js
index 4ce7a72cd..2967d5712 100644
--- a/public/js/pages/ManualInvoice/ManualInvoice.js
+++ b/public/js/pages/ManualInvoice/ManualInvoice.js
@@ -52,27 +52,21 @@ Vue.component('manual-invoice', {
},
async handleSave(invoiceData) {
try {
- const positions = invoiceData.positions.map(p => {
- const amount = parseFloat(p.amount) || 0;
- const price = parseFloat(p.price) || 0;
- const discount = parseFloat(p.discount) || 0;
- const vatrate = parseFloat(p.vatrate) || 0;
- const priceAfterDiscount = amount * price * (1 - discount / 100);
- return {
- ...p, amount, price, discount, vatrate,
- unit: p.unit || 'Stk.',
- price_total: priceAfterDiscount,
- price_gross: priceAfterDiscount * (1 + vatrate / 100),
- product_id: p.product_id || 0,
- contract_id: p.contract_id || 0,
- billing_id: p.billing_id || null,
- matchcode: p.matchcode || null,
- fibu_cost_account: p.fibu_cost_account || null,
- fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
- fibu_taxcode: p.fibu_taxcode || null,
- options: p.options || null
- };
- });
+ const positions = invoiceData.positions.map(p => ({
+ ...p,
+ amount: parseFloat(p.amount) || 0,
+ price: parseFloat(p.price) || 0,
+ discount: parseFloat(p.discount) || 0,
+ vatrate: parseFloat(p.vatrate) || 0,
+ unit: p.unit || 'Stk.',
+ warehousearticle_id: p.warehousearticle_id || p.product_id || 0,
+ warehousearticle_name: p.warehousearticle_name || p.product_name || '',
+ matchcode: p.matchcode || null,
+ fibu_cost_account: p.fibu_cost_account || null,
+ fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
+ fibu_taxcode: p.fibu_taxcode || null,
+ options: p.options || null
+ }));
const payload = {
...invoiceData,
@@ -82,11 +76,13 @@ Vue.component('manual-invoice', {
customer_number: invoiceData.customer_number || 0,
country: invoiceData.country || 'Österreich',
billing_type: invoiceData.billing_type || 'invoice',
- billing_delivery: 'email',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
- vatgroup_id: 1,
- gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0
+ vatgroup_id: invoiceData.vatgroup_id || 1,
+ performance_period: invoiceData.performance_period || invoiceData.leistungszeitraum || '',
+ introductory_text: invoiceData.introductory_text || invoiceData.einleitender_text || '',
+ external_reference: invoiceData.external_reference || invoiceData.externe_referenz || '',
+ total_discount: parseFloat(invoiceData.total_discount || invoiceData.gesamtrabatt) || 0
};
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
@@ -161,20 +157,28 @@ Vue.component('manual-invoice-modal', {
Rechnungsdetails
-
@@ -197,41 +201,48 @@ Vue.component('manual-invoice-modal', {
pdfLoading: false,
pdfPreviewUrl: '',
previewDebounceTimer: null,
+ customerBillingInfo: {
+ billing_type: 'invoice',
+ manual_invoice_sepa_limit: null,
+ vatarea: 'domestic',
+ tax_text: ''
+ },
invoiceData: {
id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'),
billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0,
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich',
- uid: '', email: '', billing_type: 'invoice', tax_text: '',
- leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0,
+ uid: '', email: '', billing_type: 'invoice', tax_text: '', vatgroup_id: 1,
+ performance_period: '', introductory_text: '', external_reference: '', total_discount: 0,
positions: [], total: 0, total_gross: 0
},
- billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
- product_name: { type: 'input', label: 'Bezeichnung' },
+ article_id: {
+ type: 'input-article',
+ label: 'Artikel',
+ apiUrl: '/WarehouseArticle/autocomplete',
+ customFieldReference: 'WarehouseArticle',
+ emitDisplayValue: true
+ },
+ warehousearticle_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
- unit: {
+ price_type: {
type: 'select',
- label: 'Einheit',
- options: [
- { value: 'Pau.', text: 'Pau.' },
- { value: 'Stk.', text: 'Stk.' },
- { value: 'h', text: 'h' },
- { value: 'm', text: 'm' }
- ]
+ label: 'Preistyp',
+ options: []
},
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
- vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (d) => {
- if (!d.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
- if (!d.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
+ if (!d.warehousearticle_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
+ if (d.amount == null || d.amount === '') { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (d.price == null) { window.notify('error', 'Preis ist erforderlich.'); return false; }
return true;
}
- }
+ },
+ articlePrices: []
};
},
computed: {
@@ -246,34 +257,46 @@ Vue.component('manual-invoice-modal', {
subtotal += lineTotal;
});
- // Apply gesamtrabatt
- const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0;
- const net = subtotal * (1 - gesamtrabatt / 100);
-
- // Calculate VAT
+ const totalDiscount = parseFloat(this.invoiceData.total_discount) || 0;
+ const net = subtotal * (1 - totalDiscount / 100);
let vat = {}, gross = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const r = parseInt(p.vatrate) || 0;
- const lineNet = amount * price * (1 - discount / 100) * (1 - gesamtrabatt / 100);
+ const lineNet = amount * price * (1 - discount / 100) * (1 - totalDiscount / 100);
const lineVat = lineNet * (r / 100);
vat[r] = (vat[r] || 0) + lineVat;
gross += lineNet + lineVat;
});
return { subtotal, net, vat, gross };
+ },
+ effectiveBillingType() {
+ if (this.customerBillingInfo.billing_type !== 'sepa') return 'invoice';
+ if (this.customerBillingInfo.manual_invoice_sepa_limit === null) return 'sepa';
+ return this.totals.gross <= this.customerBillingInfo.manual_invoice_sepa_limit ? 'sepa' : 'invoice';
}
},
watch: {
'invoiceData': { handler() { this.debouncedPreviewUpdate(); }, deep: true },
+ effectiveBillingType: {
+ handler(newType) {
+ this.invoiceData.billing_type = newType;
+ },
+ immediate: true
+ },
'invoiceData.billingaddress_id': {
async handler(newId) {
- if (!newId) return Object.assign(this.invoiceData, {
- company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
- country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
- });
+ if (!newId) {
+ Object.assign(this.invoiceData, {
+ company: '', firstname: '', lastname: '', street: '', zip: '', city: '',
+ country: 'Österreich', uid: '', email: '', customer_number: 0, fibu_account_number: 0, owner_id: 0
+ });
+ this.customerBillingInfo = { billing_type: 'invoice', manual_invoice_sepa_limit: null, vatarea: 'domestic', tax_text: '' };
+ return;
+ }
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (data.status === 'OK' && data.result.address) {
@@ -285,6 +308,8 @@ Vue.component('manual-invoice-modal', {
fibu_account_number: a.fibu_account_number || 0, owner_id: newId
});
}
+
+ await this.fetchCustomerBillingInfo(newId);
}
}
},
@@ -321,15 +346,100 @@ Vue.component('manual-invoice-modal', {
methods: {
close() { this.$emit('close'); },
saveInvoice() {
+ this.invoiceData.invoice_date = moment().format('YYYY-MM-DD');
+ this.invoiceData.billing_type = this.effectiveBillingType;
+ this.invoiceData.tax_text = this.customerBillingInfo.tax_text;
if (!this.invoiceData.billingaddress_id) return window.notify('error', 'Bitte wählen Sie einen Kunden aus.');
if (!this.invoiceData.positions?.length) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
this.$emit('save', this.invoiceData);
},
+ formatPrice(value) {
+ if (value === null || value === undefined) return '-';
+ return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value);
+ },
+ async fetchCustomerBillingInfo(addressId) {
+ if (!addressId) return;
+ try {
+ const vatgroupId = this.invoiceData.vatgroup_id || 2;
+ const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getCustomerBillingInfo?address_id=${addressId}&vatgroup_id=${vatgroupId}`);
+ if (data.success) {
+ this.customerBillingInfo = {
+ billing_type: data.billing_type || 'invoice',
+ manual_invoice_sepa_limit: data.manual_invoice_sepa_limit,
+ vatarea: data.vatarea || 'domestic',
+ tax_text: data.tax_text || ''
+ };
+ this.invoiceData.tax_text = data.tax_text || '';
+ }
+ } catch (e) {
+ console.error('Error fetching customer billing info:', e);
+ }
+ },
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
handleGlobalKeydown(e) {
if (e.ctrlKey && e.key === 'q') { e.preventDefault(); this.togglePreviewVisibility(); }
},
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
+ async onArticleSelected(articleId) {
+ if (!articleId) {
+ // Reset price type options when no article selected
+ this.articlePrices = [];
+ this.positionsConfig.fields.price_type.options = [];
+ return;
+ }
+ try {
+ const vatarea = this.customerBillingInfo.vatarea || 'domestic';
+ const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getArticleVatInfo?article_id=${articleId}&vatarea=${vatarea}`);
+ if (data.success && this.$refs.positionsManager) {
+ const pm = this.$refs.positionsManager;
+ if (data.article) {
+ pm.$set(pm.formData, 'warehousearticle_name', data.article.articleNumber + ' | ' + data.article.title);
+ pm.$set(pm.formData, 'product_info', data.article.description || '');
+ pm.$set(pm.formData, 'unit', data.article.unit || 'Stk.');
+ }
+ pm.$set(pm.formData, 'vatrate', parseFloat(data.vatrate) || 20);
+ pm.$set(pm.formData, 'fibu_cost_account', data.fibu_cost_account);
+ pm.$set(pm.formData, 'fibu_cost_account_legacy', data.fibu_cost_account_legacy);
+ pm.$set(pm.formData, 'fibu_taxcode', data.fibu_taxcode);
+ this.invoiceData.vatgroup_id = data.vatgroup_id;
+ await this.updateTaxText(data.vatgroup_id);
+
+ // Handle prices and price type selection
+ this.articlePrices = data.prices || [];
+ if (this.articlePrices.length > 0) {
+ const priceOptions = this.articlePrices.map(p => ({ value: p.title, text: `${p.title} (${this.formatPrice(p.price)})` }));
+ this.positionsConfig.fields.price_type.options = priceOptions;
+ // Set first price as default
+ pm.$set(pm.formData, 'price_type', this.articlePrices[0].title);
+ pm.$set(pm.formData, 'price', this.articlePrices[0].price);
+ } else {
+ this.positionsConfig.fields.price_type.options = [];
+ }
+ }
+ } catch (e) {
+ console.error('Error fetching article VAT info:', e);
+ }
+ },
+ onPriceTypeChanged(priceType) {
+ if (!priceType || !this.articlePrices.length) return;
+ const selectedPrice = this.articlePrices.find(p => p.title === priceType);
+ if (selectedPrice && this.$refs.positionsManager) {
+ this.$refs.positionsManager.$set(this.$refs.positionsManager.formData, 'price', selectedPrice.price);
+ }
+ },
+ async updateTaxText(vatgroupId) {
+ if (!vatgroupId) return;
+ try {
+ const vatarea = this.customerBillingInfo.vatarea || 'domestic';
+ const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getTaxText?vatgroup_id=${vatgroupId}&vatarea=${vatarea}`);
+ if (data.success) {
+ this.customerBillingInfo.tax_text = data.tax_text || '';
+ this.invoiceData.tax_text = data.tax_text || '';
+ }
+ } catch (e) {
+ console.error('Error fetching tax text:', e);
+ }
+ },
debouncedPreviewUpdate() {
clearTimeout(this.previewDebounceTimer);
this.previewDebounceTimer = setTimeout(() => this.updatePdfPreview(), 2000);
@@ -338,7 +448,7 @@ Vue.component('manual-invoice-modal', {
this.pdfLoading = true;
try {
const positions = this.invoiceData.positions
- .filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions
+ .filter(p => p.warehousearticle_name && (parseFloat(p.amount) || 0) !== 0)
.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
@@ -407,17 +517,17 @@ Vue.component('manual-invoice-modal', {
}
// Pre-fill external reference with shipping note reference
- this.invoiceData.externe_referenz = `Lieferschein #${shippingNoteData.shippingNoteId}`;
+ this.invoiceData.external_reference = `Lieferschein #${shippingNoteData.shippingNoteId}`;
// Add introductory text if shipping note has notes
if (shippingNoteData.note) {
- this.invoiceData.einleitender_text = shippingNoteData.note;
+ this.invoiceData.introductory_text = shippingNoteData.note;
}
// Add all positions (batch operation to avoid triggering watcher for each item)
if (shippingNoteData.positions && Array.isArray(shippingNoteData.positions)) {
const newPositions = shippingNoteData.positions.map(position => ({
- product_name: position.product_name || '',
+ warehousearticle_name: position.warehousearticle_name || position.product_name || '',
product_info: position.product_info || '',
amount: parseFloat(position.amount) || 0,
unit: position.unit || 'Stk.',
@@ -450,7 +560,7 @@ Vue.component('manual-invoice-modal', {
Vue.component('gutschrift-modal', {
props: ['invoiceId'],
template: `
-
+
Originalrechnung: {{ invoice.invoice_number }} - {{ invoice.customer_name }}
@@ -466,7 +576,7 @@ Vue.component('gutschrift-modal', {
|
- {{ pos.product_name }} {{ pos.product_info }} |
+ {{ pos.warehousearticle_name }} {{ pos.product_info }} |
{{ pos.original_amount }} | {{ pos.credited_amount }} |
{{ pos.available_amount }} |
|
@@ -507,7 +617,7 @@ Vue.component('gutschrift-modal', {
.map((p, i) => ({ p, i })).filter(({ i }) => this.selectedPositions[i])
.map(({ p, i }) => {
const amt = this.creditAmounts[i];
- if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.product_name}`);
+ if (amt > p.available_amount) throw new Error(`Menge zu hoch: ${p.warehousearticle_name}`);
return amt > 0 ? { ...p, amount: amt } : null;
}).filter(Boolean);
@@ -527,7 +637,7 @@ Vue.component('gutschrift-modal', {
Vue.component('send-invoice-modal', {
props: ['invoiceId'],
template: `
-
+
Rechnung aussenden
@@ -543,11 +653,11 @@ Vue.component('send-invoice-modal', {
-
@@ -582,7 +692,6 @@ Vue.component('send-invoice-modal', {
if (data.success) {
this.invoice = data.invoice;
this.emailAddress = data.invoice.email || '';
- this.selectedAction = data.invoice.email ? 'email' : 'download';
} else {
window.notify('error', data.message || 'Fehler beim Laden der Rechnung');
this.close();
diff --git a/public/js/pages/PreorderLogistics/PreorderLogistics.css b/public/js/pages/PreorderLogistics/PreorderLogistics.css
index 6678ffa59..5513bac12 100644
--- a/public/js/pages/PreorderLogistics/PreorderLogistics.css
+++ b/public/js/pages/PreorderLogistics/PreorderLogistics.css
@@ -10,4 +10,33 @@
.col-form-label {
padding-top: 0 !important
+}
+
+/* Filter panel styles */
+.filter-panel {
+ padding: 1rem !important;
+}
+
+.filter-panel .header-title {
+ font-size: 1.1rem;
+ margin-bottom: 0.75rem !important;
+}
+
+.filter-panel .row.g-2 > [class*="col-"] {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.filter-panel .form-group {
+ margin-bottom: 0;
+}
+
+.filter-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.filter-actions .btn {
+ margin-right: 0;
}
\ No newline at end of file
diff --git a/public/js/pages/PreorderLogistics/PreorderLogistics.js b/public/js/pages/PreorderLogistics/PreorderLogistics.js
index 7bda1d003..41f82d9c6 100644
--- a/public/js/pages/PreorderLogistics/PreorderLogistics.js
+++ b/public/js/pages/PreorderLogistics/PreorderLogistics.js
@@ -2,35 +2,38 @@ Vue.component('preorder-logistics', {
template: `
-
-
-
-
+
+
+
+
-
+
-
+
-
+
-
-
-
+
+
-
-
-
+
+
i { font-size: 28px; color: var(--accent); flex-shrink: 0; }
.tt-scope .router-header-text { flex-grow: 1; min-height: 39px; }
+.tt-scope .refresh-btn { padding: 8px; border-radius: 6px; flex-shrink: 0; }
+.tt-scope .refresh-btn i { font-size: 16px; margin: 0; }
.tt-scope .router-title { font-size: 16px; font-weight: 800; color: var(--text); letter-spacing: 0.3px; line-height: 1.375; }
.tt-scope .router-subtitle { font-size: 12px; color: var(--muted); font-family: var(--mono); margin-top: 2px; line-height: 1.25; }
.tt-scope .router-info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 16px; margin-right: -8px; }
diff --git a/public/js/pages/Radius/Radius.js b/public/js/pages/Radius/Radius.js
index f7f97cd2a..14bbb860f 100644
--- a/public/js/pages/Radius/Radius.js
+++ b/public/js/pages/Radius/Radius.js
@@ -44,6 +44,9 @@ const Radius = {
+
`,
@@ -59,7 +62,8 @@ const Radius = {
const options = [
{ id: 'users', name: 'Benutzer', icon: 'fa-duotone fa-users' },
{ id: 'free', name: 'Freie Benutzer', icon: 'fa-duotone fa-user-plus' },
- { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' }
+ { id: 'unused', name: 'Ungenutzte Benutzer', icon: 'fa-duotone fa-user-clock' },
+ { id: 'avmscanner', name: 'AVM Scanner', icon: 'fa-duotone fa-router' }
];
if (window.TT_CONFIG.CAN_BILLING === '1') {
@@ -84,6 +88,7 @@ const Radius = {
let refName = '';
if (v === 'free') refName = 'freeView';
else if (v === 'unused') refName = 'unusedView';
+ else if (v === 'avmscanner') refName = 'avmScannerView';
if (refName) {
this.$nextTick(() => {
diff --git a/public/js/pages/Radius/RadiusAVMScanner.js b/public/js/pages/Radius/RadiusAVMScanner.js
new file mode 100644
index 000000000..e8f1e944a
--- /dev/null
+++ b/public/js/pages/Radius/RadiusAVMScanner.js
@@ -0,0 +1,268 @@
+const RadiusAVMScanner = {
+ name: 'RadiusAVMScanner',
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Scan läuft...
+ {{ state.progress?.current || 0 }} / {{ state.progress?.total || 0 }}
+
+
+
+ Aktuell: {{ state.currentDevice.mac }}
+ ({{ state.currentDevice.ip }})
+
+
+
+
+
+
+
+
+
+ | MAC-Adresse |
+ IP-Adresse |
+ Kunde |
+ Gerätetyp |
+ Port |
+ Erledigt |
+ Gescannt |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+ {{ item.mac }}
+ |
+
+
+
+ {{ item.ip }}
+
+ —
+ |
+
+
+ {{ item.customerId }}
+
+ {{ item.customerName }}
+ |
+
+ {{ item.deviceType }}
+ {{ item.error }}
+ —
+ Repeater
+ Gateway
+ |
+
+ {{ item.port }}
+ —
+ |
+
+
+ |
+
+ {{ formatDate(item.scannedAt) }}
+ |
+
+
+
+
+
+ {{ filteredDevices.length }} von {{ state.devices.length }} Geräten
+ (Scan läuft...)
+
+ Keine Geräte
+
+
+
+ `,
+ data: () => ({
+ window: window,
+ state: null,
+ isLoading: false,
+ polling: null,
+ deviceTypeFilter: '',
+ showErledigt: true
+ }),
+ computed: {
+ progressPercent() {
+ if (!this.state?.progress?.total) return 0;
+ return Math.round((this.state.progress.current / this.state.progress.total) * 100);
+ },
+ deviceTypes() {
+ if (!this.state?.devices) return [];
+ const types = new Set();
+ this.state.devices.forEach(d => {
+ if (d.deviceType) types.add(d.deviceType);
+ });
+ return Array.from(types).sort();
+ },
+ filteredDevices() {
+ if (!this.state?.devices) return [];
+ return this.state.devices.filter(d => {
+ if (this.deviceTypeFilter && d.deviceType !== this.deviceTypeFilter) return false;
+ if (!this.showErledigt && d.erledigt) return false;
+ return true;
+ });
+ }
+ },
+ methods: {
+ initIfNeeded() {
+ this.refreshState();
+ },
+ startPolling() {
+ if (this.polling) return;
+ this.polling = setInterval(() => {
+ this.refreshState(true);
+ }, 3000); // Poll every 3 seconds
+ },
+ stopPolling() {
+ if (this.polling) {
+ clearInterval(this.polling);
+ this.polling = null;
+ }
+ },
+ async refreshState(silent = false) {
+ if (!silent) this.isLoading = true;
+ try {
+ const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerGetState`);
+ this.state = data;
+
+ // Start/stop polling based on scanning state
+ if (data.scanning && !this.polling) {
+ this.startPolling();
+ } else if (!data.scanning && this.polling) {
+ this.stopPolling();
+ }
+ } catch (e) {
+ console.error('Failed to fetch AVM scanner state:', e);
+ }
+ if (!silent) this.isLoading = false;
+ },
+ async startScan() {
+ try {
+ const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStart`);
+ if (data.success) {
+ window.notify('success', `Scan gestartet für ${data.total} Geräte`);
+ this.startPolling(); // Start polling immediately
+ } else {
+ window.notify('warning', data.message || 'Scan konnte nicht gestartet werden');
+ }
+ this.refreshState();
+ } catch (e) {
+ console.error('Failed to start scan:', e);
+ window.notify('error', 'Fehler beim Starten des Scans');
+ }
+ },
+ async stopScan() {
+ try {
+ const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerStop`);
+ if (data.success) {
+ window.notify('info', 'Scan wird gestoppt...');
+ }
+ } catch (e) {
+ console.error('Failed to stop scan:', e);
+ window.notify('error', 'Fehler beim Stoppen des Scans');
+ }
+ },
+ async toggleErledigt(mac) {
+ try {
+ await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/avmScannerToggleErledigt`, { mac });
+ this.refreshState(true);
+ } catch (e) {
+ console.error('Failed to toggle erledigt:', e);
+ window.notify('error', 'Fehler beim Aktualisieren');
+ }
+ },
+ formatDate(dateStr) {
+ if (!dateStr) return '—';
+ const d = new Date(dateStr);
+ return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
+ }
+ },
+ beforeUnmount() {
+ this.stopPolling();
+ }
+};
+
+if (window.VueApp) {
+ window.VueApp.component('radius-avm-scanner', RadiusAVMScanner);
+}
diff --git a/public/js/pages/Radius/RadiusRouterManager.js b/public/js/pages/Radius/RadiusRouterManager.js
index 47289255a..95c92c04d 100644
--- a/public/js/pages/Radius/RadiusRouterManager.js
+++ b/public/js/pages/Radius/RadiusRouterManager.js
@@ -32,6 +32,9 @@ const RadiusRouterManager = {
{{ routerDevice.username || userItem.username }}
+
@@ -40,7 +43,7 @@ const RadiusRouterManager = {
-
+
@@ -239,7 +242,8 @@ const RadiusRouterManager = {
showEventLogModal: false,
eventLogLoading: false,
- eventLogData: null
+ eventLogData: null,
+ refreshLoading: false
}),
watch: {
show: {
@@ -265,18 +269,12 @@ const RadiusRouterManager = {
this.speedtestLoading = false;
try {
- const { data: radacct } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius`, {
- params: { action2: 'fetchRadacct', username: this.userItem.username }
+ const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByMac`, {
+ params: { mac: this.userItem.username }
});
- if (radacct?.ip) {
- const { data: deviceData } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsGetDeviceByIp`, {
- params: { ip: radacct.ip }
- });
-
- if (deviceData?.success) {
- this.routerDevice = deviceData;
- }
+ if (deviceData?.success) {
+ this.routerDevice = deviceData;
}
} catch (error) {
console.error('Error fetching router:', error);
@@ -284,6 +282,25 @@ const RadiusRouterManager = {
}
this.routerLoading = false;
},
+ async refreshDevice() {
+ if (!this.routerDevice?.deviceId) return;
+ this.refreshLoading = true;
+ try {
+ const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Radius/genieacsRefreshDevice`, {
+ deviceId: this.routerDevice.deviceId
+ });
+ if (data?.success) {
+ this.routerDevice.deviceInfo = data.deviceInfo;
+ this.routerDevice.externalIp = data.externalIp;
+ this.routerDevice.managementIp = data.managementIp;
+ window.notify('success', 'Daten aktualisiert');
+ }
+ } catch (error) {
+ console.error('Error refreshing device:', error);
+ window.notify('error', 'Fehler beim Aktualisieren');
+ }
+ this.refreshLoading = false;
+ },
async rebootRouter() {
if (!this.routerDevice || !this.routerDevice.deviceId) return;
if (!confirm('Möchten Sie den Router wirklich neu starten?')) return;
diff --git a/public/js/pages/User/User.js b/public/js/pages/User/User.js
index b39a9b265..fe5cce0ab 100644
--- a/public/js/pages/User/User.js
+++ b/public/js/pages/User/User.js
@@ -3,14 +3,14 @@ Vue.component("User", {
-
+
-
+
@@ -438,8 +437,8 @@ Vue.component('warehouse-article-modal', {
@@ -539,7 +538,7 @@ Vue.component('warehouse-article-modal', {
category_id: null,
articleNumber: '',
unit: 'Stk.',
- revenueAccount: 0,
+ vatgroup_id: 2,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
@@ -567,10 +566,10 @@ Vue.component('warehouse-article-modal', {
{ value: 'km', text: 'km' }
];
},
- revenueAccountOptions() {
+ vatgroupOptions() {
return [
- { value: 0, text: 'Dienstleistungen' },
- { value: 1, text: 'Handelswaren' }
+ { value: 2, text: 'Dienstleistungen' },
+ { value: 3, text: 'Handelswaren' }
];
},
isValid() {
@@ -608,7 +607,7 @@ Vue.component('warehouse-article-modal', {
category_id: data.category_id,
articleNumber: data.articleNumber || '',
unit: data.unit || 'Stk.',
- revenueAccount: data.revenueAccount || 0,
+ vatgroup_id: data.vatgroup_id || 2,
warningAmount: data.warningAmount || 0,
criticalAmount: data.criticalAmount || 0,
isSerialDocumentation: !!data.isSerialDocumentation,
@@ -638,7 +637,7 @@ Vue.component('warehouse-article-modal', {
category_id: null,
articleNumber: '',
unit: 'Stk.',
- revenueAccount: 0,
+ vatgroup_id: 2,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
diff --git a/public/js/pages/WarehouseCategory/WarehouseCategory.js b/public/js/pages/WarehouseCategory/WarehouseCategory.js
new file mode 100644
index 000000000..adc23ef5e
--- /dev/null
+++ b/public/js/pages/WarehouseCategory/WarehouseCategory.js
@@ -0,0 +1,23 @@
+Vue.component('warehouse-category', {
+ //language=Vue
+ template: `
+
+
+
+
+
+ `, data() {
+ return {
+ window: window, historyModal: false, historyModalId: null,
+ }
+ },
+ methods: {
+ printLabels(event) {
+ const url = window.TT_CONFIG.BASE_PATH + "/WarehouseCategory/printLabels?id=" + event.id;
+ window.open(url, '_blank');
+ }
+ }
+})
diff --git a/public/js/pages/WarehouseOrder/WarehouseOrder.css b/public/js/pages/WarehouseOrder/WarehouseOrder.css
index b58b3475d..0f0211ca6 100644
--- a/public/js/pages/WarehouseOrder/WarehouseOrder.css
+++ b/public/js/pages/WarehouseOrder/WarehouseOrder.css
@@ -180,3 +180,411 @@ input:checked + .ios-switch-slider:before {
input:disabled + .ios-switch-slider {
cursor: not-allowed;
}
+
+/* ===== ORDER DETAIL REDESIGN ===== */
+
+.order-detail-container {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+.order-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
+ border-radius: 10px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+}
+
+.order-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.order-number {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #1a1a2e;
+}
+
+.order-meta {
+ font-size: 0.875rem;
+ color: #6c757d;
+}
+
+.order-meta span {
+ margin-right: 12px;
+}
+
+.order-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.status-badge {
+ padding: 6px 14px;
+ border-radius: 20px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.status-new { background: #e3f2fd; color: #1565c0; }
+.status-accepted { background: #e8f5e9; color: #2e7d32; }
+.status-ordered { background: #fff3e0; color: #ef6c00; }
+.status-sent { background: #fce4ec; color: #c2185b; }
+.status-partiallyDelivered { background: #fff8e1; color: #f9a825; }
+.status-fullyDelivered { background: #e8f5e9; color: #2e7d32; }
+.status-cancelled { background: #ffebee; color: #c62828; }
+
+/* Status Form */
+.status-form-container {
+ background: #fff;
+ border: 2px solid #e3f2fd;
+ border-radius: 10px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+}
+
+.status-form-header {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.status-form-header > div {
+ flex: 1;
+ min-width: 200px;
+}
+
+.delivery-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 16px 0;
+}
+
+.delivery-table th {
+ text-align: left;
+ padding: 10px 12px;
+ background: #f8f9fa;
+ font-weight: 600;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ color: #6c757d;
+ border-bottom: 2px solid #dee2e6;
+}
+
+.delivery-table td {
+ padding: 12px;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.delivery-table input[type="number"] {
+ width: 80px;
+ padding: 6px 10px;
+ border: 1px solid #ced4da;
+ border-radius: 6px;
+ text-align: center;
+}
+
+.delivery-table input[type="text"] {
+ width: 100%;
+ padding: 6px 10px;
+ border: 1px solid #ced4da;
+ border-radius: 6px;
+}
+
+.status-form-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid #e9ecef;
+}
+
+.file-upload-row {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-top: 16px;
+}
+
+.file-upload-item {
+ flex: 1;
+ min-width: 200px;
+}
+
+/* Section Titles */
+.section-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #6c757d;
+ margin: 24px 0 12px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.section-title i {
+ color: #1976d2;
+}
+
+/* Positions Table */
+.positions-container {
+ border-radius: 10px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+ border: 1px solid #e0e0e0;
+ margin-bottom: 24px;
+ background: #fff;
+}
+
+.positions-table {
+ display: table !important;
+ width: 100%;
+ border-collapse: collapse;
+ background: #fff;
+}
+
+.positions-table thead {
+ display: table-header-group !important;
+}
+
+.positions-table tbody {
+ display: table-row-group !important;
+}
+
+.positions-table tfoot {
+ display: table-footer-group !important;
+}
+
+.positions-table tr {
+ display: table-row !important;
+}
+
+.positions-table th,
+.positions-table td {
+ display: table-cell !important;
+ vertical-align: middle;
+ position: static !important;
+}
+
+.positions-table th {
+ text-align: left;
+ padding: 14px 20px;
+ background: #f8f9fa;
+ font-weight: 600;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: #495057;
+ border-bottom: 2px solid #dee2e6;
+ position: static !important;
+}
+
+/* Right-align numeric columns (Menge, Einzelpreis, Summe) */
+.positions-table th:nth-child(2),
+.positions-table th:nth-child(3),
+.positions-table th:nth-child(4),
+.positions-table td:nth-child(2),
+.positions-table td:nth-child(3),
+.positions-table td:nth-child(4) {
+ text-align: right;
+}
+
+.positions-table td {
+ padding: 12px 20px;
+ border-bottom: 1px solid #e9ecef;
+ font-size: 0.95rem;
+ color: #333;
+}
+
+.positions-table tbody td:first-child {
+ font-weight: 500;
+ color: #1a1a2e;
+}
+
+.positions-table tbody tr:nth-child(even) {
+ background: #fafbfc;
+}
+
+.positions-table tbody tr:hover {
+ background: #f0f4f8;
+}
+
+/* Total row in tfoot */
+.positions-table tfoot .total-row {
+ background: #f0f0f0;
+}
+
+.positions-table tfoot .total-row td {
+ color: #333;
+ font-weight: 600;
+ font-size: 1rem;
+ padding: 14px 20px;
+ border-bottom: none;
+ border-top: 2px solid #dee2e6;
+}
+
+.positions-table tfoot .total-row td:first-child {
+ font-weight: 700;
+}
+
+/* Movements Table */
+.movements-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.movements-table th {
+ text-align: left;
+ padding: 8px 12px;
+ background: #f8f9fa;
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ color: #6c757d;
+}
+
+.movements-table td {
+ padding: 10px 12px;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.movements-table a {
+ color: #1976d2;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.movements-table a:hover {
+ text-decoration: underline;
+}
+
+.movement-qty {
+ color: #2e7d32;
+ font-weight: 600;
+}
+
+/* Timeline */
+.timeline-container {
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 10px;
+ padding: 20px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.05);
+}
+
+.timeline {
+ position: relative;
+ padding-left: 24px;
+ margin-left: 8px;
+}
+
+.timeline::before {
+ content: '';
+ position: absolute;
+ left: 3px;
+ top: 6px;
+ bottom: 6px;
+ width: 2px;
+ background: #dee2e6;
+ border-radius: 1px;
+}
+
+.timeline-item {
+ position: relative;
+ padding-bottom: 20px;
+}
+
+.timeline-item:last-child {
+ padding-bottom: 0;
+}
+
+.timeline-marker {
+ position: absolute;
+ left: -21px;
+ top: 4px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #fff;
+ border: 2px solid #1976d2;
+ z-index: 1;
+}
+
+.timeline-item.is-first .timeline-marker {
+ background: #1976d2;
+ width: 12px;
+ height: 12px;
+ left: -22px;
+ top: 3px;
+}
+
+.timeline-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.timeline-date {
+ font-size: 0.8rem;
+ color: #6c757d;
+ font-weight: 500;
+}
+
+.timeline-author {
+ font-size: 0.8rem;
+ color: #1976d2;
+ font-weight: 600;
+}
+
+.timeline-body {
+ font-size: 0.9rem;
+ color: #333;
+ line-height: 1.5;
+ white-space: pre-line;
+}
+
+.timeline-files {
+ margin-top: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.timeline-files a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ background: #e3f2fd;
+ color: #1565c0;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ text-decoration: none;
+}
+
+.timeline-files a:hover {
+ background: #bbdefb;
+}
+
+.loading-spinner {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 40px;
+}
diff --git a/public/js/pages/WarehouseOrder/WarehouseOrder.js b/public/js/pages/WarehouseOrder/WarehouseOrder.js
index 501439c38..8c1e73ae8 100644
--- a/public/js/pages/WarehouseOrder/WarehouseOrder.js
+++ b/public/js/pages/WarehouseOrder/WarehouseOrder.js
@@ -11,20 +11,32 @@ Vue.component('change-status-modal', {
note: '',
file: null,
uploadedFiles: [],
+ deliveryNoteFiles: [],
sendEmail: false,
sendEmailViewedPDF: false,
sendEmailMail: '',
submitLoading: false,
- deliveredPositions: {} // To track delivery details for each position
+ deliveredPositions: {}, // To track delivery details for each position
+ warehouseLocations: [],
+ selectedLocationId: null
};
},
async mounted() {
- const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}});
- if (response.data.status === 'cancelled') {
+ const [orderResponse, locationsResponse] = await Promise.all([
+ axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}}),
+ axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getLocations`)
+ ]);
+
+ if (orderResponse.data.status === 'cancelled') {
this.$emit('close');
window.notify('error', 'Bestellung wurde storniert');
}
- this.order = response.data;
+ this.order = orderResponse.data;
+ this.warehouseLocations = locationsResponse.data;
+
+ // Set default location to "K1 Fladnitz 150" if available
+ const defaultLocation = this.warehouseLocations.find(loc => loc.text === 'K1 Fladnitz 150');
+ this.selectedLocationId = defaultLocation ? defaultLocation.value : (this.warehouseLocations[0]?.value || null);
// Initialize deliveredPositions after fetching the order
if (this.order && this.order.positions) {
@@ -40,6 +52,10 @@ Vue.component('change-status-modal', {
}
},
computed: {
+ movementPreviewCount() {
+ if (!this.deliveredPositions) return 0;
+ return Object.values(this.deliveredPositions).filter(p => p.amount > 0).length;
+ },
availableStatuses() {
// This computed property remains unchanged
switch (this.order.status) {
@@ -86,8 +102,7 @@ Vue.component('change-status-modal', {
}
},
methods: {
- async handleFileUpload(event) {
- // This method remains unchanged
+ async handleFileUpload(event, isDeliveryNote = false) {
const files = event.target.files;
if (!files.length) return;
@@ -104,16 +119,21 @@ Vue.component('change-status-modal', {
});
if (response.data.success) {
- this.uploadedFiles.push({
+ const fileEntry = {
id: response.data.fileId,
name: file.name
- });
- window.notify('success', `File "${file.name}" uploaded successfully`);
+ };
+ if (isDeliveryNote) {
+ this.deliveryNoteFiles.push(fileEntry);
+ } else {
+ this.uploadedFiles.push(fileEntry);
+ }
+ window.notify('success', `Datei "${file.name}" erfolgreich hochgeladen`);
} else {
- window.notify('error', `File "${file.name}" upload failed: ${response.data.error || 'Unknown error'}`);
+ window.notify('error', `Datei "${file.name}" Upload fehlgeschlagen: ${response.data.error || 'Unbekannter Fehler'}`);
}
} catch (error) {
- window.notify('error', `Error uploading file "${file.name}"`);
+ window.notify('error', `Fehler beim Hochladen von "${file.name}"`);
}
}
event.target.value = '';
@@ -121,6 +141,9 @@ Vue.component('change-status-modal', {
removeFile(index) {
this.uploadedFiles.splice(index, 1)
},
+ removeDeliveryNoteFile(index) {
+ this.deliveryNoteFiles.splice(index, 1)
+ },
async submit() {
this.submitLoading = true;
if (this.newStatus === 'accepted' && this.sendEmail && !this.sendEmailMail) {
@@ -139,6 +162,7 @@ Vue.component('change-status-modal', {
}
const fileIds = this.uploadedFiles.map(file => file.id);
+ const deliveryNoteFileIds = this.deliveryNoteFiles.map(file => file.id);
// Prepare delivery data if the status is related to delivery
let deliveryData = null;
@@ -151,7 +175,9 @@ Vue.component('change-status-modal', {
status: this.newStatus,
note: this.note,
fileIds: JSON.stringify(fileIds),
- deliveryData: deliveryData // Send the new delivery data to the backend
+ deliveryData: deliveryData, // Send the new delivery data to the backend
+ locationId: this.selectedLocationId,
+ deliveryNoteFileIds: deliveryNoteFileIds
});
if (response.data.success) {
@@ -230,6 +256,12 @@ Vue.component('change-status-modal', {
+ Lagerstandort
+
+
Positionen Lieferung erfassen
Artikel
@@ -270,6 +302,32 @@ Vue.component('change-status-modal', {
+
+
+
+ Es werden {{ movementPreviewCount }} Lagerbewegung(en) erstellt.
+
+
+ Lieferschein Foto
+
+
+
+
+
+
+
+ -
+
+ {{ file.name }}
+
+
+
+
@@ -581,60 +639,399 @@ Vue.component('tt-file', {
Vue.component('warehouse-order-detail', {
template: `
-
- Bestellungsdetails für #{{ loading ? 'Laden...' : order.orderNumber }}
+
-
- Loading...
+
-
- Positionen
-
-
- {{ p.articleName }}
- {{ p.amount }}
- {{ p.buyPrice }}
- {{ p.distributorName }}
- {{ p.verwendung }}
- {{ p.amount * p.buyPrice }}
-
-
-
- Log
-
- {{ formatDate(log.create) }} ({{ getUserName(log.createBy) }}) | {{ log.message }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Positionen
+
+
+
+
+ | Artikel |
+ Menge |
+ Einzelpreis |
+ Summe |
+
+
+
+
+ | {{ p.articleName }} |
+ {{ p.amount }} Stk |
+ {{ formatCurrency(p.buyPrice) }} |
+ {{ formatCurrency(p.amount * p.buyPrice) }} |
+
+
+
+
+ | Gesamtsumme |
+ |
+ |
+ {{ formatCurrency(grandTotal) }} |
+
+
+
+
+
+
+
+ Lagerbewegungen
+
+
+
+ | Nummer |
+ Artikel |
+ Menge |
+ Lagerort |
+ Datum |
+
+
+
+
+ | {{ m.movementNumber }} |
+ {{ m.articleName }} |
+ +{{ m.quantity }} |
+ {{ m.locationName }} |
+ {{ formatDate(m.create) }} |
+
+
+
+
+
+
+
+ Aktivität
+
+
+
+
+
+
+ {{ log.message }}
+
+
+
+
-
- Lieferadresse
- {{ order[field] }}
- {{ order.delAddrPLZ }} {{ order.delAddrCity }}
-
+
`,
- props: ['id'],
- data: () => ({order: {}, orderLog: null, loading: true}),
+ props: ['id'],
+ data() {
+ return {
+ order: {},
+ orderLog: null,
+ linkedMovements: [],
+ loading: true,
+ window: window,
+ // Status form state
+ showStatusForm: false,
+ newStatus: 'noChanges',
+ selectedLocationId: null,
+ warehouseLocations: [],
+ deliveredPositions: {},
+ uploadedFiles: [],
+ deliveryNoteFiles: [],
+ note: '',
+ isSubmitting: false
+ };
+ },
+ computed: {
+ grandTotal() {
+ return this.order.positions?.reduce((sum, p) => sum + (p.amount * p.buyPrice), 0) || 0;
+ },
+ statusLabel() {
+ const labels = {
+ new: 'Neu',
+ accepted: 'Akzeptiert',
+ ordered: 'Bestellt',
+ sent: 'Versendet',
+ partiallyDelivered: 'Teilweise geliefert',
+ fullyDelivered: 'Geliefert',
+ cancelled: 'Storniert'
+ };
+ return labels[this.order.status] || this.order.status;
+ },
+ availableStatuses() {
+ const statusMap = {
+ new: [
+ {value: 'noChanges', text: 'Keine Änderungen'},
+ {value: 'accepted', text: 'Akzeptiert'},
+ {value: 'ordered', text: 'Bestellt'},
+ {value: 'cancelled', text: 'Storniert'}
+ ],
+ accepted: [
+ {value: 'noChanges', text: 'Keine Änderungen'},
+ {value: 'ordered', text: 'Bestellt'},
+ {value: 'cancelled', text: 'Storniert'}
+ ],
+ ordered: [
+ {value: 'noChanges', text: 'Keine Änderungen'},
+ {value: 'sent', text: 'Versendet'},
+ {value: 'fullyDelivered', text: 'Geliefert'},
+ {value: 'partiallyDelivered', text: 'Teilweise geliefert'},
+ {value: 'cancelled', text: 'Storniert'}
+ ],
+ sent: [
+ {value: 'noChanges', text: 'Keine Änderungen'},
+ {value: 'partiallyDelivered', text: 'Teilweise geliefert'},
+ {value: 'fullyDelivered', text: 'Geliefert'},
+ {value: 'cancelled', text: 'Storniert'}
+ ],
+ partiallyDelivered: [
+ {value: 'noChanges', text: 'Keine Änderungen'},
+ {value: 'fullyDelivered', text: 'Geliefert'},
+ {value: 'cancelled', text: 'Storniert'}
+ ]
+ };
+ return statusMap[this.order.status] || [{value: 'noChanges', text: 'Keine Änderungen'}];
+ },
+ isDeliveryStatus() {
+ return this.newStatus === 'partiallyDelivered' || this.newStatus === 'fullyDelivered';
+ },
+ movementPreviewCount() {
+ if (!this.deliveredPositions) return 0;
+ return Object.values(this.deliveredPositions).filter(p => p.amount > 0).length;
+ },
+ sortedLog() {
+ if (!this.orderLog) return [];
+ return [...this.orderLog].sort((a, b) => b.create - a.create);
+ }
+ },
async mounted() {
- const [orderResponse, logResponse] = await Promise.all([
+ const [orderResponse, logResponse, movementsResponse, locationsResponse] = await Promise.all([
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getById`, {params: {id: this.id}}),
- axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}})
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}}),
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLinkedMovements`, {params: {orderId: this.id}}),
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLocations`)
]);
this.order = orderResponse.data;
this.orderLog = logResponse.data;
+ this.linkedMovements = movementsResponse.data || [];
+ this.warehouseLocations = locationsResponse.data || [];
+
+ // Set default location
+ const defaultLoc = this.warehouseLocations.find(l => l.text === 'K1 Fladnitz 150');
+ this.selectedLocationId = defaultLoc ? defaultLoc.value : (this.warehouseLocations[0]?.value || null);
+
this.loading = false;
},
methods: {
- formatDate: date => window.moment(date * 1000).format('DD.MM.YYYY HH:mm'),
- getUserName: id => window.TT_CONFIG.CRUD_CONFIG.columns.find(col => col.key === 'createBy')?.modal.items.find(u => u.value === id)?.text
+ formatDate: date => window.moment(date * 1000).format('DD.MM.YYYY HH:mm'),
+ formatCurrency(value) {
+ return new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(value);
+ },
+ getUserName: id => window.TT_CONFIG.CRUD_CONFIG.columns.find(col => col.key === 'createBy')?.modal.items.find(u => u.value === id)?.text || 'Unbekannt',
+ toggleStatusForm() {
+ this.showStatusForm = !this.showStatusForm;
+ if (this.showStatusForm) {
+ this.initDeliveredPositions();
+ }
+ },
+ initDeliveredPositions() {
+ this.deliveredPositions = {};
+ if (this.order && this.order.positions) {
+ this.order.positions.forEach((pos, index) => {
+ this.$set(this.deliveredPositions, index, {
+ amount: pos.amount,
+ reason: '',
+ cancelRest: false,
+ articleName: pos.articleName,
+ orderedAmount: pos.amount
+ });
+ });
+ }
+ },
+ async handleFileUpload(event, isDeliveryNote) {
+ const files = event.target.files;
+ if (!files.length) return;
+
+ for (const file of files) {
+ const formData = new FormData();
+ formData.append('file', file);
+ try {
+ const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/uploadFile`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+ if (response.data.success) {
+ const entry = { id: response.data.fileId, name: file.name };
+ if (isDeliveryNote) {
+ this.deliveryNoteFiles.push(entry);
+ } else {
+ this.uploadedFiles.push(entry);
+ }
+ }
+ } catch (e) {
+ window.notify('error', 'Fehler beim Hochladen');
+ }
+ }
+ event.target.value = '';
+ },
+ cancelStatusChange() {
+ this.showStatusForm = false;
+ this.newStatus = 'noChanges';
+ this.note = '';
+ this.uploadedFiles = [];
+ this.deliveryNoteFiles = [];
+ },
+ async submitStatusChange() {
+ this.isSubmitting = true;
+ try {
+ const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/createNewLogAction`, {
+ orderId: this.order.id,
+ status: this.newStatus,
+ note: this.note,
+ fileIds: JSON.stringify(this.uploadedFiles.map(f => f.id)),
+ deliveryData: this.isDeliveryStatus ? this.deliveredPositions : null,
+ locationId: this.selectedLocationId,
+ deliveryNoteFileIds: this.deliveryNoteFiles.map(f => f.id)
+ });
+
+ if (response.data.success) {
+ window.notify('success', 'Status erfolgreich geändert');
+ this.cancelStatusChange();
+ // Reload data
+ const [orderRes, logRes, movRes] = await Promise.all([
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getById`, {params: {id: this.id}}),
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLogById`, {params: {id: this.id}}),
+ axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOrder/getLinkedMovements`, {params: {orderId: this.id}})
+ ]);
+ this.order = orderRes.data;
+ this.orderLog = logRes.data;
+ this.linkedMovements = movRes.data || [];
+ // Emit event to refresh table
+ this.$emit('status-changed');
+ } else {
+ window.notify('error', response.data.error || 'Fehler beim Speichern');
+ }
+ } catch (e) {
+ window.notify('error', 'Netzwerkfehler');
+ }
+ this.isSubmitting = false;
+ }
}
});
@@ -642,22 +1039,19 @@ Vue.component('warehouse-order', {
template: `
-
-
+
{{ calculateSum(JSON.parse(row["positions"])).toFixed(2) }} €
`,
data: () => ({
- orderModalId: null,
- changeStatusModalId: null
+ orderModalId: null
}),
mounted() {
if (JSON.parse(localStorage.getItem('WarehouseOrder_create'))) this.orderModalId = 'create';
@@ -665,10 +1059,12 @@ Vue.component('warehouse-order', {
methods: {
async closeModal() {
this.orderModalId = null;
- this.changeStatusModalId = null;
await new Promise(resolve => setTimeout(resolve, 250));
this.$refs.table.$refs.table.refreshTable();
},
+ refreshTable() {
+ this.$refs.table.$refs.table.refreshTable();
+ },
calculateSum: positions => positions.reduce((sum, {amount, buyPrice}) => sum + amount * buyPrice, 0),
openPDF: order => window.open(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/createPDF?id=${order.id}`)
}
diff --git a/public/js/pages/WorkorderAdmin/WorkorderAdmin.js b/public/js/pages/WorkorderAdmin/WorkorderAdmin.js
index 561f6f1ab..0cf9a40d4 100644
--- a/public/js/pages/WorkorderAdmin/WorkorderAdmin.js
+++ b/public/js/pages/WorkorderAdmin/WorkorderAdmin.js
@@ -90,8 +90,8 @@ Vue.component('workorder-admin', {
{{ formatDate(row.appointmentDate, true) }}
-
-
+
diff --git a/public/js/pages/WorkorderBase/WorkorderBase.js b/public/js/pages/WorkorderBase/WorkorderBase.js
index 3972717c4..758b31278 100644
--- a/public/js/pages/WorkorderBase/WorkorderBase.js
+++ b/public/js/pages/WorkorderBase/WorkorderBase.js
@@ -27,25 +27,56 @@ Vue.component('civil-engineering-manager', {
-
+
- Tiefbau-Arbeiten
+ Tiefbau-Arbeiten
Schließen Sie den Tiefbau-Auftrag ab. Laden Sie Dokumente hoch, falls erforderlich.
+
+
+ Checkliste
+
+ -
+
+ {{ docType.text }}
+
+
+
+
-
- Bitte laden Sie mindestens ein Dokument hoch, um den Auftrag abzuschließen.
+
+ Bitte laden Sie mindestens ein Tiefbau-Dokument hoch, um den Auftrag abzuschließen.
+
+
+
+
+
+ -
+ {{ formatDate(log.create) }} ({{ log.createByName }}):
+
{{ log.text }}
+
+
+ Keine Journaleinträge.
+
+
+
+
-
+
Dokument hochladen
+
@@ -56,14 +87,21 @@ Vue.component('civil-engineering-manager', {
+
+ @delete-file="deleteDocumentation"
+ @update-file="updateDocumentation">
+
+
+
- Für diesen Auftraggeber ist keine Dokumentation für Tiefbau-Arbeiten erforderlich.
+
+
+ Für diesen Auftraggeber ist keine Dokumentation für Tiefbau-Arbeiten erforderlich.
+
@@ -72,25 +110,43 @@ Vue.component('civil-engineering-manager', {
`,
data: () => ({
- loading: true, uploading: false, completing: false, docsRequired: false,
- uploadedFiles: [], uploadData: { files: [], description: '' }, showCompleteModal: false
+ loading: true, uploading: false, completing: false,
+ docsRequired: false, tiefbauSeesNormalDocs: false, documentationTypes: [],
+ uploadedFiles: [], journals: [],
+ uploadData: { files: [], description: '', documentType: 'civil_engineering_photo' },
+ showCompleteModal: false,
+ newJournalMessage: '', addingJournalEntry: false
}),
computed: {
canComplete() {
+ if (this.tiefbauSeesNormalDocs) {
+ // When tiefbauSeesNormalDocs is enabled, can always complete (no strict requirements)
+ return true;
+ }
+ // Original logic: require at least one civil engineering doc if docsRequired
return !this.docsRequired || this.uploadedFiles.length > 0;
+ },
+ allDocTypes() {
+ return [...this.documentationTypes, { value: 'civil_engineering_photo', text: 'Tiefbau Foto' }, { value: 'other', text: 'Sonstiges' }];
}
},
methods: {
+ formatDate(timestamp) { return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : '–'; },
+ isUploaded(docType) {
+ return Array.isArray(this.uploadedFiles) && this.uploadedFiles.some(doc => doc.documentType === docType);
+ },
async fetchInitialData() {
this.loading = true;
try {
const configRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/getTenantConfig`, { params: { workorderId: this.workorderId } });
this.docsRequired = configRes.data.civilEngineeringDocsRequired || false;
+ this.tiefbauSeesNormalDocs = configRes.data.tiefbauSeesNormalDocs || false;
+ this.documentationTypes = configRes.data.documentationTypes || [];
- if(this.docsRequired) {
- const docRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/getDocumentation`, { params: { workorderId: this.workorderId } });
- this.uploadedFiles = docRes.data.docs || [];
- }
+ // Always load docs and journals
+ const docRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/getDocumentation`, { params: { workorderId: this.workorderId } });
+ this.uploadedFiles = docRes.data.docs || [];
+ this.journals = docRes.data.journals || [];
} catch (e) {
window.notify('error', 'Konfiguration konnte nicht geladen werden.');
console.error(e);
@@ -104,7 +160,7 @@ Vue.component('civil-engineering-manager', {
this.uploading = true;
const formData = new FormData();
formData.append('workorderId', this.workorderId);
- formData.append('documentType', 'civil_engineering_photo');
+ formData.append('documentType', this.uploadData.documentType || 'civil_engineering_photo');
formData.append('description', this.uploadData.description);
for (const file of this.uploadData.files) formData.append('files[]', file);
try {
@@ -112,7 +168,7 @@ Vue.component('civil-engineering-manager', {
if (data.success) {
window.notify('success', data.message);
this.$refs.fileInput.value = '';
- this.uploadData = { files: [], description: '' };
+ this.uploadData = { files: [], description: '', documentType: this.tiefbauSeesNormalDocs ? this.documentationTypes[0]?.value : 'civil_engineering_photo' };
await this.fetchInitialData();
} else window.notify('error', data.error || 'Upload fehlgeschlagen.');
} catch (e) {
@@ -131,6 +187,29 @@ Vue.component('civil-engineering-manager', {
window.notify('error', 'Netzwerkfehler beim Löschen.');
}
},
+ async updateDocumentation(file) {
+ try {
+ const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/updateDocumentation`, { id: file.id, documentType: file.documentType });
+ if (data.success) {
+ window.notify('success', data.message);
+ await this.fetchInitialData();
+ } else window.notify('error', data.message || 'Update fehlgeschlagen.');
+ } catch (e) {
+ window.notify('error', 'Netzwerkfehler beim Update.');
+ }
+ },
+ async addJournalEntry() {
+ if (!this.newJournalMessage.trim()) return;
+ this.addingJournalEntry = true;
+ try {
+ const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/addJournal`, { workorderId: this.workorderId, text: this.newJournalMessage });
+ if (data.success) {
+ this.newJournalMessage = '';
+ this.journals = data.journals || [];
+ } else window.notify('error', data.message);
+ } catch (e) { window.notify('error', 'Netzwerkfehler'); }
+ finally { this.addingJournalEntry = false; }
+ },
async completeTask() {
this.completing = true;
try {
@@ -273,7 +352,65 @@ Vue.component('workorder-details-manager', {
/>
-
+
+
+
+
+
+
+
+
+
+ {{ technicalData.patchposition.equipmentName }}
+ Port {{ technicalData.patchposition.equipmentPort }}
+
+
+
+
+
+
+
+ {{ wo.rimoName }}
+ {{ wo.rimoStatus }}
+
+
+
+
+
+
+
+
+ | Kabel-ID | Typ | PLAN | IST | Status |
+
+
+
+ | {{ dk.cable_id }} |
+ {{ dk.type }} |
+ {{ dk.laenge_plan || '-' }} |
+ {{ dk.laenge_ist || '-' }} |
+ {{ dk.status || '-' }} |
+
+
+
+
+
+
+
+
+
+
+
+
@@ -328,6 +465,10 @@ Vue.component('workorder-details-manager', {
requireCableLength: false,
requireCableType: false,
savingData: false,
+ // Technical data
+ showTechnicalData: false,
+ technicalData: null,
+ parsingAhaId: null,
// Admin state
selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false,
}),
@@ -377,6 +518,8 @@ Vue.component('workorder-details-manager', {
// FIX: Ensure docs and journals are always arrays
this.docs = docsJournalsRes.data.docs || [];
this.journals = docsJournalsRes.data.journals || [];
+ // Reload tenant config to get updated technical data (AHA may have been auto-parsed)
+ if (this.showTechnicalData) this.loadTenantConfig();
} catch (e) {
window.notify('error', 'Details konnten nicht geladen werden.');
this.docs = []; // Ensure it's an array on error
@@ -394,6 +537,8 @@ Vue.component('workorder-details-manager', {
this.interventionTypes = data.interventionTypes;
this.requireCableLength = data.requireCableLength || false;
this.requireCableType = data.requireCableType || false;
+ this.showTechnicalData = data.showTechnicalData || false;
+ this.technicalData = data.technicalData || null;
}
} catch (e) { console.error("Mandantenkonfiguration nicht geladen", e); }
finally { this.loadingConfig = false; }
@@ -534,6 +679,24 @@ Vue.component('workorder-details-manager', {
this.showAcceptModal = false;
},
getInterventionLabel(type) { return this.interventionTypes.find(t => t.value === type)?.text || type; },
+ async parseAha(wo) {
+ this.parsingAhaId = wo.id;
+ try {
+ const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RimoWorkorder/parseAha`, { id: wo.id });
+ if (data.success) {
+ window.notify('success', `AHA-Daten geladen: ${data.dropkabel_count} Dropkabel${data.has_map ? ', Lageplan vorhanden' : ''}`);
+ // Reload technical data to show the parsed data
+ await this.loadTenantConfig();
+ } else {
+ window.notify('error', data.message || 'Fehler beim Laden der AHA-Daten');
+ }
+ } catch (e) {
+ window.notify('error', 'Netzwerkfehler beim Laden der AHA-Daten');
+ console.error(e);
+ } finally {
+ this.parsingAhaId = null;
+ }
+ },
async revertDocumentedStatus() {
// Optional: Add loading state if needed
try {
diff --git a/public/js/pages/WorkorderCompany/WorkorderCompany.js b/public/js/pages/WorkorderCompany/WorkorderCompany.js
index ed6e0ec46..a974eb9bc 100644
--- a/public/js/pages/WorkorderCompany/WorkorderCompany.js
+++ b/public/js/pages/WorkorderCompany/WorkorderCompany.js
@@ -65,6 +65,9 @@ Vue.component('workorder-company', {
+
–
@@ -157,6 +160,16 @@ Vue.component('workorder-company', {
this.rescheduleModalData = null;
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
},
+ async clearAppointment(workorder) {
+ if (!confirm('Möchten Sie den Termin wirklich löschen?')) return;
+ const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/clearAppointment`, {
+ workorderId: workorder.id
+ });
+ if (data.success) {
+ window.notify('success', data.message);
+ this.$refs.table.$refs.table.refreshTable();
+ } else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
+ },
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';
diff --git a/public/js/pages/WorkorderDashboard/WorkorderDashboard.css b/public/js/pages/WorkorderDashboard/WorkorderDashboard.css
new file mode 100644
index 000000000..7aad806cb
--- /dev/null
+++ b/public/js/pages/WorkorderDashboard/WorkorderDashboard.css
@@ -0,0 +1,135 @@
+.tt-scope.workorder-dashboard {
+ --wd-primary: #4f46e5; --wd-primary-light: #6366f1;
+ --wd-success: #10b981; --wd-success-light: #34d399;
+ --wd-warning: #f59e0b; --wd-warning-light: #fbbf24;
+ --wd-danger: #ef4444; --wd-danger-light: #f87171;
+ --wd-info: #06b6d4; --wd-info-light: #22d3ee;
+ --wd-gray: #6b7280; --wd-gray-light: #9ca3af;
+ --wd-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --wd-card-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --wd-card-radius: 12px; --wd-card-radius-sm: 8px;
+ padding: 0; min-height: 100vh;
+ background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
+}
+
+.tt-scope.workorder-dashboard .filter-bar {
+ background: white; padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); position: sticky; top: 0; z-index: 100;
+}
+.tt-scope.workorder-dashboard .filter-bar__inner { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
+.tt-scope.workorder-dashboard .filter-bar__inner > .form-group { margin-bottom: 0; }
+.tt-scope.workorder-dashboard .filter-bar .tt-select { min-width: 200px; }
+.tt-scope.workorder-dashboard .filter-bar .tt-select:first-child { min-width: 220px; }
+.tt-scope.workorder-dashboard .filter-bar .form-group input[type="text"] { min-width: 240px; }
+.tt-scope.workorder-dashboard .filter-bar .refresh-btn { height: 31px; margin-bottom: 0; align-self: flex-end; }
+
+.tt-scope.workorder-dashboard .refresh-btn {
+ display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem;
+ background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light));
+ border: none; border-radius: var(--wd-card-radius-sm); color: white; font-weight: 500;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+.tt-scope.workorder-dashboard .refresh-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); }
+.tt-scope.workorder-dashboard .refresh-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.tt-scope.workorder-dashboard .dashboard-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; }
+
+.tt-scope.workorder-dashboard .kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
+.tt-scope.workorder-dashboard .kpi-card {
+ display: flex; align-items: center; gap: 1rem; padding: 1.25rem;
+ border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow);
+ color: white; transition: transform 0.2s, box-shadow 0.2s;
+}
+.tt-scope.workorder-dashboard .kpi-card:hover { transform: translateY(-2px); box-shadow: var(--wd-card-shadow-hover); }
+.tt-scope.workorder-dashboard .kpi-card--primary { background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light)); }
+.tt-scope.workorder-dashboard .kpi-card--success { background: linear-gradient(135deg, var(--wd-success), var(--wd-success-light)); }
+.tt-scope.workorder-dashboard .kpi-card--warning { background: linear-gradient(135deg, var(--wd-warning), var(--wd-warning-light)); }
+.tt-scope.workorder-dashboard .kpi-card--danger { background: linear-gradient(135deg, var(--wd-danger), var(--wd-danger-light)); }
+.tt-scope.workorder-dashboard .kpi-card--info { background: linear-gradient(135deg, var(--wd-info), var(--wd-info-light)); }
+.tt-scope.workorder-dashboard .kpi-card__icon {
+ width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
+ background: rgba(255, 255, 255, 0.2); border-radius: 50%; font-size: 1.25rem;
+}
+.tt-scope.workorder-dashboard .kpi-card__content { flex: 1; }
+.tt-scope.workorder-dashboard .kpi-card__value { font-size: 1.75rem; font-weight: 700; line-height: 1.2; }
+.tt-scope.workorder-dashboard .kpi-card__title { font-size: 0.875rem; font-weight: 500; opacity: 0.9; }
+.tt-scope.workorder-dashboard .kpi-card__subtitle { font-size: 0.75rem; opacity: 0.75; margin-top: 0.25rem; }
+
+.tt-scope.workorder-dashboard .charts-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; }
+.tt-scope.workorder-dashboard .analytics-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
+@media (max-width: 1024px) { .tt-scope.workorder-dashboard .charts-row, .tt-scope.workorder-dashboard .analytics-row { grid-template-columns: 1fr; } }
+
+.tt-scope.workorder-dashboard .chart-card {
+ background: white; border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow);
+ padding: 1.5rem; transition: box-shadow 0.2s;
+}
+.tt-scope.workorder-dashboard .chart-card:hover { box-shadow: var(--wd-card-shadow-hover); }
+.tt-scope.workorder-dashboard .chart-card--wide { grid-column: span 1; }
+.tt-scope.workorder-dashboard .chart-card--full { width: 100%; }
+.tt-scope.workorder-dashboard .chart-card__title {
+ display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 600;
+ color: #1f2937; margin: 0 0 1rem 0; padding-bottom: 0.75rem; border-bottom: 1px solid #f3f4f6;
+}
+.tt-scope.workorder-dashboard .chart-card__title i { color: var(--wd-primary); }
+
+.tt-scope.workorder-dashboard .chart-wrapper { height: 250px; position: relative; }
+.tt-scope.workorder-dashboard .chart-wrapper--large { height: 300px; }
+.tt-scope.workorder-dashboard .chart-wrapper--wide { height: 200px; }
+
+.tt-scope.workorder-dashboard .detail-table-wrapper { overflow-x: auto; max-height: 400px; overflow-y: auto; }
+.tt-scope.workorder-dashboard .detail-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
+.tt-scope.workorder-dashboard .detail-table th {
+ position: sticky; top: 0; background: #f9fafb; padding: 0.75rem 1rem;
+ text-align: left; font-weight: 600; color: #374151; border-bottom: 2px solid #e5e7eb;
+}
+.tt-scope.workorder-dashboard .detail-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
+.tt-scope.workorder-dashboard .detail-table tr:hover td { background: #f9fafb; }
+.tt-scope.workorder-dashboard .company-cell { font-weight: 500; color: #1f2937; }
+.tt-scope.workorder-dashboard .count-cell { font-weight: 600; color: var(--wd-primary); }
+.tt-scope.workorder-dashboard .campaigns-cell { display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.tt-scope.workorder-dashboard .campaign-tag { display: inline-block; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 4px; font-size: 0.75rem; color: #4b5563; }
+
+.tt-scope.workorder-dashboard .status-badge {
+ display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px;
+ font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em;
+}
+.tt-scope.workorder-dashboard .status-badge--completed, .tt-scope.workorder-dashboard .status-badge--charged { background: rgba(16, 185, 129, 0.1); color: #059669; }
+.tt-scope.workorder-dashboard .status-badge--assigned, .tt-scope.workorder-dashboard .status-badge--new { background: rgba(59, 130, 246, 0.1); color: #2563eb; }
+.tt-scope.workorder-dashboard .status-badge--scheduled, .tt-scope.workorder-dashboard .status-badge--in_progress { background: rgba(245, 158, 11, 0.1); color: #d97706; }
+.tt-scope.workorder-dashboard .status-badge--intervention_required, .tt-scope.workorder-dashboard .status-badge--correction_requested { background: rgba(239, 68, 68, 0.1); color: #dc2626; }
+.tt-scope.workorder-dashboard .status-badge--documented, .tt-scope.workorder-dashboard .status-badge--problem_solved { background: rgba(20, 184, 166, 0.1); color: #0d9488; }
+.tt-scope.workorder-dashboard .status-badge--archived, .tt-scope.workorder-dashboard .status-badge--cancelled { background: rgba(107, 114, 128, 0.1); color: #4b5563; }
+
+.tt-scope.workorder-dashboard .intervention-rates { display: flex; flex-direction: column; gap: 0.375rem; max-height: 300px; overflow-y: auto; }
+.tt-scope.workorder-dashboard .intervention-rate-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0.5rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); }
+.tt-scope.workorder-dashboard .intervention-rate-item .company-name { flex: 0 0 120px; font-weight: 500; font-size: 0.8125rem; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.tt-scope.workorder-dashboard .intervention-rate-item__bar { flex: 1; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; min-width: 60px; }
+.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
+.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--success { background: var(--wd-success); }
+.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--warning { background: var(--wd-warning); }
+.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--danger { background: var(--wd-danger); }
+.tt-scope.workorder-dashboard .intervention-rate-item .rate-value { flex: 0 0 50px; font-weight: 600; font-size: 0.8125rem; text-align: right; }
+.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--success { color: var(--wd-success); }
+.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--warning { color: var(--wd-warning); }
+.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--danger { color: var(--wd-danger); }
+.tt-scope.workorder-dashboard .intervention-rate-item__details { flex: 0 0 auto; display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--wd-gray); }
+
+.tt-scope.workorder-dashboard .transitions-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; }
+.tt-scope.workorder-dashboard .transition-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); font-size: 0.875rem; }
+.tt-scope.workorder-dashboard .transition-label { color: #374151; }
+.tt-scope.workorder-dashboard .transition-count { font-weight: 600; color: var(--wd-primary); background: rgba(79, 70, 229, 0.1); padding: 0.25rem 0.5rem; border-radius: 4px; }
+
+.tt-scope.workorder-dashboard .loading-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; color: var(--wd-gray); }
+.tt-scope.workorder-dashboard .spinner { width: 48px; height: 48px; border: 3px solid #e5e7eb; border-top-color: var(--wd-primary); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.tt-scope.workorder-dashboard .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; text-align: center; color: var(--wd-gray); }
+.tt-scope.workorder-dashboard .empty-state i { font-size: 4rem; margin-bottom: 1rem; opacity: 0.3; }
+.tt-scope.workorder-dashboard .empty-state h3 { margin: 0 0 0.5rem 0; color: #374151; }
+.tt-scope.workorder-dashboard .empty-state p { margin: 0; }
+.tt-scope.workorder-dashboard .no-data { text-align: center; padding: 2rem; color: var(--wd-gray); font-style: italic; }
+
+@media (max-width: 768px) {
+ .tt-scope.workorder-dashboard .filter-bar__inner { flex-direction: column; align-items: stretch; }
+ .tt-scope.workorder-dashboard .kpi-row { grid-template-columns: 1fr 1fr; }
+ .tt-scope.workorder-dashboard .kpi-card { flex-direction: column; text-align: center; }
+}
diff --git a/public/js/pages/WorkorderDashboard/WorkorderDashboard.js b/public/js/pages/WorkorderDashboard/WorkorderDashboard.js
new file mode 100644
index 000000000..5a9adb5f5
--- /dev/null
+++ b/public/js/pages/WorkorderDashboard/WorkorderDashboard.js
@@ -0,0 +1,276 @@
+Vue.component('wd-kpi-card', {
+ props: {
+ title: { type: String, required: true },
+ value: { type: [Number, String], required: true },
+ icon: { type: String, default: 'fas fa-chart-bar' },
+ color: { type: String, default: 'primary' },
+ suffix: { type: String, default: '' },
+ subtitle: { type: String, default: '' }
+ },
+ template: `
+
+
+
+ {{ value }}{{ suffix }}
+ {{ title }}
+ {{ subtitle }}
+
+
+ `
+});
+
+Vue.component('wd-status-chart', {
+ props: { data: { type: Array, required: true } },
+ template: ' ',
+ data() { return { chart: null }; },
+ mounted() { this.renderChart(); },
+ watch: { data: { handler() { this.renderChart(); }, deep: true } },
+ methods: {
+ renderChart() {
+ if (this.chart) this.chart.destroy();
+ if (!this.data || this.data.length === 0) return;
+ this.chart = new Chart(this.$refs.chart.getContext('2d'), {
+ type: 'doughnut',
+ data: {
+ labels: this.data.map(d => d.label),
+ datasets: [{ data: this.data.map(d => d.count), backgroundColor: this.data.map(d => d.color), borderWidth: 2, borderColor: '#fff' }]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false, cutout: '60%',
+ plugins: {
+ legend: { position: 'right', labels: { padding: 15, usePointStyle: true, font: { size: 11 } } },
+ tooltip: { callbacks: { label: (ctx) => `${ctx.label}: ${ctx.raw} (${((ctx.raw / ctx.dataset.data.reduce((a, b) => a + b, 0)) * 100).toFixed(1)}%)` } }
+ }
+ }
+ });
+ }
+ }
+});
+
+Vue.component('wd-company-chart', {
+ props: { data: { type: Array, required: true } },
+ template: ' ',
+ data() { return { chart: null }; },
+ mounted() { this.renderChart(); },
+ watch: { data: { handler() { this.renderChart(); }, deep: true } },
+ methods: {
+ renderChart() {
+ if (this.chart) this.chart.destroy();
+ if (!this.data || this.data.length === 0) return;
+ this.chart = new Chart(this.$refs.chart.getContext('2d'), {
+ type: 'bar',
+ data: {
+ labels: this.data.map(d => d.company),
+ datasets: [
+ { label: 'Abgeschlossen', data: this.data.map(d => d.completed), backgroundColor: '#10b981', borderRadius: 4 },
+ { label: 'In Bearbeitung', data: this.data.map(d => d.pending), backgroundColor: '#f59e0b', borderRadius: 4 },
+ { label: 'Probleme', data: this.data.map(d => d.issues), backgroundColor: '#ef4444', borderRadius: 4 }
+ ]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false, indexAxis: 'y',
+ plugins: { legend: { position: 'top', labels: { usePointStyle: true } } },
+ scales: { x: { stacked: true, grid: { display: false } }, y: { stacked: true, grid: { display: false } } }
+ }
+ });
+ }
+ }
+});
+
+Vue.component('wd-trends-chart', {
+ props: { data: { type: Array, required: true } },
+ template: ' ',
+ data() { return { chart: null }; },
+ mounted() { this.renderChart(); },
+ watch: { data: { handler() { this.renderChart(); }, deep: true } },
+ methods: {
+ renderChart() {
+ if (this.chart) this.chart.destroy();
+ if (!this.data || this.data.length === 0) return;
+ this.chart = new Chart(this.$refs.chart.getContext('2d'), {
+ type: 'line',
+ data: {
+ labels: this.data.map(d => d.date),
+ datasets: [
+ { label: 'Erstellt', data: this.data.map(d => d.created), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 },
+ { label: 'Abgeschlossen', data: this.data.map(d => d.completed), borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 }
+ ]
+ },
+ options: {
+ responsive: true, maintainAspectRatio: false,
+ plugins: { legend: { position: 'top', labels: { usePointStyle: true } } },
+ scales: {
+ x: { type: 'time', time: { unit: 'day', displayFormats: { day: 'DD.MM' } }, grid: { display: false } },
+ y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }
+ }
+ }
+ });
+ }
+ }
+});
+
+Vue.component('wd-intervention-rates', {
+ props: { data: { type: Array, required: true } },
+ template: `
+
+
+ {{ item.company }}
+
+ {{ item.rate }}%
+ {{ item.total }} Ges.
+
+ Keine Daten verfügbar
+
+ `,
+ methods: {
+ getRateClass(rate) { return rate >= 20 ? 'rate--danger' : rate >= 10 ? 'rate--warning' : 'rate--success'; }
+ }
+});
+
+Vue.component('workorder-dashboard', {
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+ Dashboard wird geladen...
+
+
+ Bitte wählen Sie einen Mandanten aus
+ Wählen Sie oben einen Mandanten, um das Dashboard anzuzeigen.
+
+
+
+
+
+
+
+
+
+
+
+ Status-Verteilung
+
+
+
+ Firmen-Performance
+
+
+
+
+ Zeitlicher Verlauf
+
+
+
+ Firma → Status → Kampagne (Detailansicht)
+
+
+ | Firma | Status | Anzahl | Kampagnen-Aufschlüsselung |
+
+
+ | {{ row.company }} |
+ {{ row.statusLabel }} |
+ {{ row.count }} |
+ {{ c.name }}: {{ c.count }} |
+
+ | Keine Daten verfügbar |
+
+
+
+
+
+
+ Problemquote pro Firma
+
+
+
+ Häufigste Status-Übergänge
+
+
+ {{ t.transition }}
+ {{ t.count }}
+
+ Keine Daten verfügbar
+
+
+
+
+
+ `,
+ data() {
+ return {
+ selectedTenant: '', dateRange: null, selectedCompany: '', selectedStatus: '', selectedCampaign: '',
+ filterOptions: { tenants: [], companies: [], statuses: [], campaigns: [] },
+ kpis: null, statusDistribution: [], companyPerformance: [], timeTrends: [],
+ companyStatusCampaign: [], interventionRates: [], statusTransitions: [],
+ loading: false
+ };
+ },
+ async mounted() {
+ const today = new Date();
+ today.setHours(23, 59, 59, 0);
+ const threeMonthsAgo = new Date(today);
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+ threeMonthsAgo.setHours(0, 0, 0, 0);
+ this.dateRange = { from: Math.floor(threeMonthsAgo.getTime() / 1000), to: Math.floor(today.getTime() / 1000) };
+ await this.fetchFilterOptions();
+ },
+ methods: {
+ async fetchFilterOptions() {
+ try {
+ const response = await axios.get(`${window.TT_CONFIG['BASE_URL']}/getFilterOptions`);
+ this.filterOptions.tenants = response.data.tenants;
+ this.filterOptions.companies = response.data.companies;
+ this.filterOptions.statuses = response.data.statuses;
+ } catch (error) { console.error('Error fetching filter options:', error); }
+ },
+ async onTenantChange(tenantId) {
+ this.selectedTenant = tenantId;
+ this.selectedCampaign = '';
+ if (tenantId) {
+ try {
+ const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getCampaignsForTenant`, { tenantId });
+ this.filterOptions.campaigns = response.data;
+ } catch (error) { console.error('Error fetching campaigns:', error); }
+ await this.fetchDashboardData();
+ }
+ },
+ onDateRangeChange(range) { this.dateRange = range; this.onFilterChange(); },
+ onFilterChange() { if (this.selectedTenant) this.fetchDashboardData(); },
+ async fetchDashboardData() {
+ if (!this.selectedTenant) return;
+ this.loading = true;
+ try {
+ const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getDashboardData`, {
+ tenantId: this.selectedTenant,
+ dateFrom: this.dateRange?.from || null,
+ dateTo: this.dateRange?.to || null,
+ companyIds: this.selectedCompany ? [this.selectedCompany] : [],
+ statuses: this.selectedStatus ? [this.selectedStatus] : [],
+ campaignIds: this.selectedCampaign ? [this.selectedCampaign] : []
+ });
+ this.kpis = response.data.kpis;
+ this.statusDistribution = response.data.statusDistribution;
+ this.companyPerformance = response.data.companyPerformance;
+ this.timeTrends = response.data.timeTrends;
+ this.companyStatusCampaign = response.data.companyStatusCampaign;
+ this.interventionRates = response.data.interventionRates;
+ this.statusTransitions = response.data.statusTransitions;
+ } catch (error) {
+ console.error('Error fetching dashboard data:', error);
+ alert('Fehler beim Laden der Dashboard-Daten.');
+ } finally { this.loading = false; }
+ }
+ }
+});
diff --git a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js
index 72191b6e8..be5226746 100644
--- a/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js
+++ b/public/js/pages/WorkorderMphAdmin/WorkorderMphAdmin.js
@@ -30,10 +30,13 @@ Vue.component('workorder-mph-admin', {
{{ row.companyName || 'N/A' }}
-
-
+
+
@@ -101,6 +104,13 @@ Vue.component('workorder-mph-admin', {
Soll der Auftrag #{{ cancelWorkorderModalData.id }} wirklich storniert werden?
+
+
+ Soll die Zuweisung für Auftrag #{{ unassignWorkorderModalData.id }} aufgehoben werden?
+ Aktuell zugewiesen an: {{ unassignWorkorderModalData.companyName }}
+
+
`,
data() {
@@ -113,6 +123,7 @@ Vue.component('workorder-mph-admin', {
companies: [],
companiesLoading: false,
cancelWorkorderModalData: null,
+ unassignWorkorderModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
@@ -237,6 +248,20 @@ Vue.component('workorder-mph-admin', {
} else {
window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
}
+ },
+ async unassignWorkorder() {
+ const { id, reason } = this.unassignWorkorderModalData;
+ const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/unassignWorkorder`, {
+ workorderId: id,
+ reason: reason
+ });
+ if (data.success) {
+ window.notify('success', data.message);
+ this.$refs.table.$refs.table.refreshTable();
+ this.unassignWorkorderModalData = null;
+ } else {
+ window.notify('error', data.message || 'Aufheben der Zuweisung fehlgeschlagen.');
+ }
}
}
});
\ No newline at end of file
diff --git a/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js b/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js
index 681f3bca6..4eaba32cb 100644
--- a/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js
+++ b/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js
@@ -89,6 +89,10 @@ Vue.component('workorder-tenant-config', {
v-model="editableItem.requireCableLength" sm/>
+
+
Workorder: {{ config.enableWorkorder ? 'Aktiviert' : 'Deaktiviert' }}
@@ -97,6 +101,8 @@ Vue.component('workorder-tenant-config', {
Tiefbau-Doku: {{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}
Kabellänge-Doku: {{ config.requireCableLength ? 'Ja' : 'Nein' }}
Kabeltyp-Doku: {{ config.requireCableType ? 'Ja' : 'Nein' }}
+ Technische Daten: {{ config.showTechnicalData ? 'Ja' : 'Nein' }}
+ Tiefbau sieht Doku: {{ config.tiefbauSeesNormalDocs ? 'Ja' : 'Nein' }}
@@ -333,6 +339,8 @@ Vue.component('workorder-tenant-config', {
civilEngineeringDocsRequired: 0,
requireCableLength: 0,
requireCableType: 0,
+ showTechnicalData: 0,
+ tiefbauSeesNormalDocs: 0,
enableWorkorder: 1,
enableWorkorderMph: 1
}
diff --git a/public/mobile/app.js b/public/mobile/app.js
new file mode 100644
index 000000000..2442bc899
--- /dev/null
+++ b/public/mobile/app.js
@@ -0,0 +1,850 @@
+import { authState, checkAuth, login, logout } from '/mobile/shared/auth.js';
+import LoginScreen from '/mobile/components/LoginScreen.js';
+import MainMenu from '/mobile/components/MainMenu.js';
+import LagerModule from '/mobile/modules/lager/LagerModule.js';
+import ShippingNoteModule from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+import WorkorderModule from '/mobile/modules/workorder/WorkorderModule.js';
+import OfflineIndicator from '/mobile/components/OfflineIndicator.js';
+import SyncStatus from '/mobile/components/SyncStatus.js';
+import { initDatabase, clearAllData, getStorageEstimate } from '/mobile/shared/db.js';
+import offlineSettings from '/mobile/shared/offlineSettings.js';
+import SyncManager from '/mobile/shared/syncManager.js';
+
+const { createApp, ref, computed, onMounted, onUnmounted, watch } = Vue;
+
+const isPWAInstalled = () => {
+ if (window.matchMedia('(display-mode: standalone)').matches) return true;
+ if (window.navigator.standalone === true) return true;
+ if (document.referrer.includes('android-app://')) return true;
+ return false;
+};
+
+const shouldRequirePWA = () => {
+ return window.location.hostname === 'thetool.xinon.at';
+};
+
+const parseInitialRoute = () => {
+ const initialPath = window.TT_CONFIG?.INITIAL_PATH || '/MobileApp';
+ const parts = initialPath.replace('/MobileApp', '').split('/').filter(Boolean);
+ return {
+ module: parts[0] || null,
+ submodule: parts[1] || null
+ };
+};
+
+const App = {
+ components: {
+ LoginScreen,
+ MainMenu,
+ LagerModule,
+ ShippingNoteModule,
+ WorkorderModule,
+ OfflineIndicator,
+ SyncStatus
+ },
+
+ setup() {
+ const currentView = ref('loading');
+ const user = ref(null);
+ const toast = ref({ show: false, message: '', type: 'success' });
+ const theme = ref('system');
+ const showSettings = ref(false);
+ const lagerSimpleMode = ref(false);
+ const currentModule = ref(null);
+ const currentSubmodule = ref(null);
+ const lastWorkflow = ref(null);
+ const showContinuePrompt = ref(false);
+ const showInstallPrompt = ref(false);
+ const deferredInstallPrompt = ref(null);
+ const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
+ const isAndroid = ref(/Android/.test(navigator.userAgent));
+ const canGoBack = computed(() => currentModule.value !== null || workorderDetailOpen.value);
+ const workorderDetailOpen = ref(false);
+ const workorderRef = ref(null);
+
+ // Offline mode state
+ const offlineModeEnabled = ref(false);
+ const offlineAutoSync = ref(true);
+ const offlinePendingCount = ref(0);
+ const offlinePendingOps = ref(0);
+ const offlinePendingPhotos = ref(0);
+ const offlineFailedCount = ref(0);
+ const offlineIsSyncing = ref(false);
+ const offlineLastSyncText = ref('Nie synchronisiert');
+ const offlineFreshness = ref('unknown');
+ const offlineSyncProgress = ref(null);
+ const offlineStorageUsed = ref(0);
+ const isOnline = ref(navigator.onLine);
+
+ const applyTheme = () => {
+ const isDark = localStorage.theme === 'dark' ||
+ (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ document.documentElement.classList.toggle('dark', isDark);
+
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]');
+ if (metaThemeColor) {
+ metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384');
+ }
+ };
+
+ const setTheme = (newTheme) => {
+ theme.value = newTheme;
+ if (newTheme === 'system') {
+ localStorage.removeItem('theme');
+ } else {
+ localStorage.setItem('theme', newTheme);
+ }
+ applyTheme();
+ };
+
+ const handleInstallPrompt = (e) => {
+ e.preventDefault();
+ deferredInstallPrompt.value = e;
+ };
+
+ const triggerInstall = async () => {
+ if (!deferredInstallPrompt.value) return;
+ deferredInstallPrompt.value.prompt();
+ const { outcome } = await deferredInstallPrompt.value.userChoice;
+ if (outcome === 'accepted') {
+ showInstallPrompt.value = false;
+ window.location.reload();
+ }
+ deferredInstallPrompt.value = null;
+ };
+
+ const loadLagerSettings = () => {
+ try {
+ const saved = localStorage.getItem('movement_settings');
+ if (saved) {
+ const settings = JSON.parse(saved);
+ lagerSimpleMode.value = settings.simpleMode || false;
+ }
+ } catch (e) {}
+ };
+
+ const setLagerSimpleMode = (value) => {
+ lagerSimpleMode.value = value;
+ try {
+ const saved = localStorage.getItem('movement_settings');
+ const settings = saved ? JSON.parse(saved) : {};
+ settings.simpleMode = value;
+ localStorage.setItem('movement_settings', JSON.stringify(settings));
+ } catch (e) {}
+ };
+
+ // Offline mode functions
+ const loadOfflineSettings = () => {
+ const settings = offlineSettings.load();
+ offlineModeEnabled.value = settings.enabled;
+ offlineAutoSync.value = settings.autoSync;
+ offlineLastSyncText.value = offlineSettings.getLastSyncText();
+ offlineFreshness.value = offlineSettings.getFreshness();
+ };
+
+ const toggleOfflineMode = async () => {
+ if (offlineModeEnabled.value) {
+ offlineSettings.disable();
+ offlineModeEnabled.value = false;
+ } else {
+ offlineSettings.enable();
+ offlineModeEnabled.value = true;
+ // Initialize database
+ try {
+ await initDatabase();
+ SyncManager.init();
+ } catch (error) {
+ console.error('Failed to initialize offline mode:', error);
+ showToast('Offline-Modus konnte nicht aktiviert werden', 'error');
+ offlineSettings.disable();
+ offlineModeEnabled.value = false;
+ }
+ }
+ };
+
+ const setOfflineAutoSync = (value) => {
+ offlineAutoSync.value = value;
+ offlineSettings.setAutoSync(value);
+ };
+
+ const triggerManualSync = async () => {
+ if (!navigator.onLine) {
+ showToast('Keine Internetverbindung', 'error');
+ return;
+ }
+ const result = await SyncManager.sync();
+ if (result.success) {
+ showToast('Synchronisation abgeschlossen', 'success');
+ } else {
+ showToast(result.error || 'Synchronisation fehlgeschlagen', 'error');
+ }
+ };
+
+ const clearOfflineData = async () => {
+ try {
+ await clearAllData();
+ offlineSettings.clear();
+ offlineModeEnabled.value = false;
+ offlinePendingCount.value = 0;
+ showToast('Offline-Daten gelöscht', 'success');
+ } catch (error) {
+ showToast('Fehler beim Löschen', 'error');
+ }
+ };
+
+ const updateOfflineStatus = async () => {
+ if (offlineModeEnabled.value) {
+ const summary = await SyncManager.getPendingSummary();
+ offlinePendingCount.value = summary.total;
+ offlinePendingOps.value = summary.operations;
+ offlinePendingPhotos.value = summary.photos;
+ offlineFailedCount.value = summary.failed;
+ offlineLastSyncText.value = offlineSettings.getLastSyncText();
+ offlineFreshness.value = offlineSettings.getFreshness();
+ const storage = await getStorageEstimate();
+ offlineStorageUsed.value = storage.usage;
+ }
+ };
+
+ const handleSyncEvent = (event, data) => {
+ switch (event) {
+ case 'sync-start':
+ offlineIsSyncing.value = true;
+ offlineSyncProgress.value = null;
+ break;
+ case 'sync-progress':
+ offlineSyncProgress.value = data;
+ break;
+ case 'sync-complete':
+ offlineIsSyncing.value = false;
+ offlineSyncProgress.value = null;
+ updateOfflineStatus();
+ if (data.reassigned?.length > 0) {
+ for (const wo of data.reassigned) {
+ showToast(`Arbeitsauftrag #${wo.id} wurde neu zugewiesen`, 'warning');
+ }
+ }
+ break;
+ case 'sync-error':
+ offlineIsSyncing.value = false;
+ offlineSyncProgress.value = null;
+ break;
+ }
+ };
+
+ const handleOnlineStatusChange = () => {
+ isOnline.value = navigator.onLine;
+ };
+
+ const saveLastWorkflow = (module, submodule) => {
+ if (module) {
+ const workflow = { module, submodule: submodule || null, timestamp: Date.now() };
+ localStorage.setItem('lastWorkflow', JSON.stringify(workflow));
+ lastWorkflow.value = workflow;
+ }
+ };
+
+ const loadLastWorkflow = () => {
+ try {
+ const saved = localStorage.getItem('lastWorkflow');
+ if (saved) {
+ const workflow = JSON.parse(saved);
+ if (Date.now() - workflow.timestamp < 24 * 60 * 60 * 1000) {
+ return workflow;
+ }
+ }
+ } catch (e) {}
+ return null;
+ };
+
+ const navigate = (module, submodule = null) => {
+ currentModule.value = module;
+ currentSubmodule.value = submodule;
+ showContinuePrompt.value = false;
+ saveLastWorkflow(module, submodule);
+ let path = '/MobileApp';
+ if (module) path += '/' + module;
+ if (submodule) path += '/' + submodule;
+ history.pushState({ module, submodule }, '', path);
+ };
+
+ const continueLastWorkflow = () => {
+ if (lastWorkflow.value) {
+ navigate(lastWorkflow.value.module, lastWorkflow.value.submodule);
+ }
+ };
+
+ const dismissContinuePrompt = () => {
+ showContinuePrompt.value = false;
+ };
+
+ const goHome = () => {
+ navigate(null, null);
+ };
+
+ const goBack = () => {
+ if (workorderDetailOpen.value && workorderRef.value?.closeDetail) {
+ workorderRef.value.closeDetail();
+ return;
+ }
+ if (currentSubmodule.value) {
+ navigate(currentModule.value, null);
+ } else if (currentModule.value) {
+ navigate(null, null);
+ }
+ };
+
+ const handleWorkorderDetailOpen = (workorderId) => {
+ workorderDetailOpen.value = true;
+ };
+
+ const handleWorkorderDetailClose = () => {
+ workorderDetailOpen.value = false;
+ };
+
+ const handlePopstate = (event) => {
+ if (workorderDetailOpen.value && workorderRef.value?.closeDetail) {
+ workorderRef.value.closeDetail();
+ return;
+ }
+ if (event.state) {
+ currentModule.value = event.state.module;
+ currentSubmodule.value = event.state.submodule;
+ } else {
+ currentModule.value = null;
+ currentSubmodule.value = null;
+ }
+ };
+
+ const handleLogin = async (credentials) => {
+ if (credentials._2faSuccess) {
+ user.value = credentials.user;
+ currentView.value = 'app';
+ showToast('Erfolgreich angemeldet', 'success');
+ return { success: true };
+ }
+
+ const result = await login(credentials);
+ if (result.success) {
+ user.value = result.user;
+ currentView.value = 'app';
+ showToast('Erfolgreich angemeldet', 'success');
+ }
+ return result;
+ };
+
+ const handleLogout = async () => {
+ await logout();
+ user.value = null;
+ currentModule.value = null;
+ currentSubmodule.value = null;
+ currentView.value = 'login';
+ showToast('Abgemeldet', 'success');
+ };
+
+ const showToast = (message, type = 'success') => {
+ toast.value = { show: true, message, type };
+ setTimeout(() => { toast.value.show = false; }, 3000);
+ };
+
+ const currentComponent = computed(() => {
+ if (currentView.value !== 'app') return null;
+ if (!currentModule.value) return 'MainMenu';
+ if (currentModule.value.toLowerCase() === 'lieferschein') return 'ShippingNoteModule';
+ if (currentModule.value.toLowerCase() === 'lager') return 'LagerModule';
+ if (currentModule.value.toLowerCase() === 'workorder') return 'WorkorderModule';
+ return 'MainMenu';
+ });
+
+ const breadcrumbs = computed(() => {
+ const crumbs = [{ label: 'Home', module: null, submodule: null }];
+ if (currentModule.value) {
+ crumbs.push({ label: currentModule.value, module: currentModule.value, submodule: null });
+ }
+ if (currentSubmodule.value) {
+ crumbs.push({ label: currentSubmodule.value, module: currentModule.value, submodule: currentSubmodule.value });
+ }
+ return crumbs;
+ });
+
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+ onMounted(async () => {
+ const savedTheme = localStorage.getItem('theme');
+ if (savedTheme) theme.value = savedTheme;
+ applyTheme();
+ mediaQuery.addEventListener('change', applyTheme);
+ window.addEventListener('popstate', handlePopstate);
+ window.addEventListener('beforeinstallprompt', handleInstallPrompt);
+ window.addEventListener('online', handleOnlineStatusChange);
+ window.addEventListener('offline', handleOnlineStatusChange);
+ loadLagerSettings();
+ loadOfflineSettings();
+
+ // Initialize offline mode if enabled
+ if (offlineModeEnabled.value) {
+ try {
+ await initDatabase();
+ SyncManager.init();
+ SyncManager.subscribe(handleSyncEvent);
+ await updateOfflineStatus();
+ } catch (error) {
+ console.error('Failed to initialize offline mode:', error);
+ }
+ }
+
+ if (shouldRequirePWA() && !isPWAInstalled()) {
+ showInstallPrompt.value = true;
+ currentView.value = 'install';
+ return;
+ }
+
+ const result = await checkAuth();
+ if (result.authenticated) {
+ user.value = result.user;
+ currentView.value = 'app';
+ const initialRoute = parseInitialRoute();
+ currentModule.value = initialRoute.module;
+ currentSubmodule.value = initialRoute.submodule;
+ history.replaceState(
+ { module: initialRoute.module, submodule: initialRoute.submodule },
+ '',
+ window.location.pathname
+ );
+ if (!initialRoute.module && !initialRoute.submodule) {
+ const saved = loadLastWorkflow();
+ if (saved) {
+ lastWorkflow.value = saved;
+ showContinuePrompt.value = true;
+ }
+ }
+ } else {
+ currentView.value = 'login';
+ }
+ });
+
+ onUnmounted(() => {
+ mediaQuery.removeEventListener('change', applyTheme);
+ window.removeEventListener('popstate', handlePopstate);
+ window.removeEventListener('beforeinstallprompt', handleInstallPrompt);
+ window.removeEventListener('online', handleOnlineStatusChange);
+ window.removeEventListener('offline', handleOnlineStatusChange);
+ if (offlineModeEnabled.value) {
+ SyncManager.destroy();
+ }
+ });
+
+ return {
+ currentView,
+ user,
+ toast,
+ theme,
+ showSettings,
+ currentModule,
+ currentSubmodule,
+ currentComponent,
+ canGoBack,
+ breadcrumbs,
+ handleLogin,
+ handleLogout,
+ navigate,
+ goHome,
+ goBack,
+ showToast,
+ setTheme,
+ lagerSimpleMode,
+ setLagerSimpleMode,
+ showInstallPrompt,
+ deferredInstallPrompt,
+ isIOS,
+ isAndroid,
+ triggerInstall,
+ lastWorkflow,
+ showContinuePrompt,
+ continueLastWorkflow,
+ dismissContinuePrompt,
+ workorderRef,
+ handleWorkorderDetailOpen,
+ handleWorkorderDetailClose,
+ // Offline mode
+ offlineModeEnabled,
+ offlineAutoSync,
+ offlinePendingCount,
+ offlinePendingOps,
+ offlinePendingPhotos,
+ offlineFailedCount,
+ offlineIsSyncing,
+ offlineLastSyncText,
+ offlineFreshness,
+ offlineSyncProgress,
+ offlineStorageUsed,
+ isOnline,
+ toggleOfflineMode,
+ setOfflineAutoSync,
+ triggerManualSync,
+ clearOfflineData,
+ };
+ },
+
+ template: `
+
+
+
+ 
+ 
+ Lädt...
+
+
+
+
+
+
+
+
+ 
+ 
+
+
+
+
+ App installieren
+
+ Für die beste Erfahrung installiere die App auf deinem Gerät.
+
+
+
+
+
+
+
+
+
+ So installierst du die App:
+
+ -
+ 1
+ Tippe auf das Teilen-Symbol
+
+
+
+ -
+ 2
+ Scrolle und wähle "Zum Home-Bildschirm"
+
+ -
+ 3
+ Tippe auf "Hinzufügen"
+
+
+
+
+
+
+
+ So installierst du die App:
+
+ -
+ 1
+ Tippe auf das Menü (⋮) oben rechts
+
+ -
+ 2
+ Wähle "App installieren" oder "Zum Startbildschirm hinzufügen"
+
+ -
+ 3
+ Bestätige mit "Installieren"
+
+
+
+
+
+
+
+
+ Hinweis: Diese App ist für mobile Geräte optimiert. Bitte öffne diese Seite auf deinem Smartphone und installiere die App.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fortsetzen
+
+ {{ lastWorkflow?.module }} › {{ lastWorkflow.submodule }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Einstellungen
+
+
+
+
+ {{ user?.name }}
+ {{ user?.username }}
+
+
+
+ Farbschema
+
+
+
+
+
+
+
+
+ Lager
+
+
+ Simpel Modus
+ Weniger Optionen
+
+
+
+
+
+
+ Offline-Modus (Workorder)
+
+
+
+ Offline-Modus
+ Arbeitsaufträge offline verfügbar
+
+
+
+
+
+
+
+ Auto-Sync
+ Automatisch synchronisieren
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ toast.message }}
+
+
+
+
+ `
+};
+
+createApp(App).mount('#app');
diff --git a/public/mobile/components/LoginScreen.js b/public/mobile/components/LoginScreen.js
new file mode 100644
index 000000000..8e1fd7075
--- /dev/null
+++ b/public/mobile/components/LoginScreen.js
@@ -0,0 +1,621 @@
+import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
+
+export default {
+ name: 'LoginScreen',
+ emits: ['login', 'set-theme'],
+ props: {
+ theme: {
+ type: String,
+ default: 'system'
+ }
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted, onUnmounted, nextTick } = Vue;
+
+ const username = ref('');
+ const password = ref('');
+ const rememberMe = ref(true);
+ const showPassword = ref(false);
+ const show2FA = ref(false);
+ const otpCode = ref('');
+ const otpDigits = ref(['', '', '', '', '']);
+ const deliveryMethod = ref('');
+ const maskedTarget = ref('');
+ const resendCooldown = ref(0);
+ const error = ref('');
+ const success = ref('');
+ const loading = ref(false);
+ const showThemePicker = ref(false);
+ let otpInputRefs = [];
+ let otpAbortController = null;
+ let resendTimer = null;
+
+ const handleSubmit = async () => {
+ if (!username.value || !password.value) {
+ error.value = 'Bitte Benutzername und Passwort eingeben';
+ return;
+ }
+
+ loading.value = true;
+ error.value = '';
+
+ try {
+ const result = await login({
+ username: username.value,
+ password: password.value,
+ rememberMe: rememberMe.value
+ });
+
+ if (result.requires2FA) {
+ // Show 2FA verification screen
+ show2FA.value = true;
+ deliveryMethod.value = result.deliveryMethod;
+ maskedTarget.value = result.maskedTarget;
+ success.value = result.message;
+ error.value = '';
+
+ // Start resend cooldown
+ startResendCooldown();
+
+ // Focus first OTP input after render
+ await nextTick();
+ focusOtpInput(0);
+
+ // Try Web OTP API for SMS
+ if (result.deliveryMethod === 'sms') {
+ startWebOTP();
+ }
+ } else if (result.success) {
+ // Direct login success (no 2FA) - notify parent
+ emit('login', { _2faSuccess: true, user: result.user });
+ } else {
+ error.value = result.message || 'Login fehlgeschlagen';
+ }
+ } catch (e) {
+ error.value = 'Ein Fehler ist aufgetreten';
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Handle 2FA verification
+ const handleVerify2FA = async () => {
+ const code = otpDigits.value.join('');
+
+ if (code.length !== 5) {
+ error.value = 'Bitte gib den 5-stelligen Code ein';
+ return;
+ }
+
+ loading.value = true;
+ error.value = '';
+ success.value = '';
+
+ try {
+ const result = await verify2FA(code);
+
+ if (result.success) {
+ // Emit the successful result to parent (which handles navigation)
+ emit('login', { _2faSuccess: true, user: result.user });
+ } else {
+ error.value = result.message || 'Ungültiger Code';
+
+ if (result.expired || result.codeExpired) {
+ // Session or code expired - go back to login
+ resetTo2FA();
+ }
+ }
+ } catch (e) {
+ error.value = 'Ein Fehler ist aufgetreten';
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Handle resend 2FA code
+ const handleResend = async () => {
+ if (resendCooldown.value > 0) return;
+
+ loading.value = true;
+ error.value = '';
+
+ try {
+ const result = await resend2FA();
+
+ if (result.success) {
+ success.value = result.message || 'Neuer Code wurde gesendet';
+ startResendCooldown();
+
+ // Clear OTP inputs
+ otpDigits.value = ['', '', '', '', ''];
+ focusOtpInput(0);
+
+ // Restart Web OTP if SMS
+ if (deliveryMethod.value === 'sms') {
+ startWebOTP();
+ }
+ } else {
+ error.value = result.message || 'Code konnte nicht gesendet werden';
+
+ if (result.expired) {
+ resetTo2FA();
+ }
+ }
+ } catch (e) {
+ error.value = 'Ein Fehler ist aufgetreten';
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ const backToLogin = () => {
+ show2FA.value = false;
+ otpDigits.value = ['', '', '', '', ''];
+ otpInputRefs = [];
+ error.value = '';
+ success.value = '';
+ abortWebOTP();
+ };
+
+ const resetTo2FA = () => {
+ show2FA.value = false;
+ password.value = '';
+ otpDigits.value = ['', '', '', '', ''];
+ otpInputRefs = [];
+ error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
+ };
+
+ // Start resend cooldown (30 seconds)
+ const startResendCooldown = () => {
+ resendCooldown.value = 30;
+ if (resendTimer) clearInterval(resendTimer);
+ resendTimer = setInterval(() => {
+ resendCooldown.value--;
+ if (resendCooldown.value <= 0) {
+ clearInterval(resendTimer);
+ }
+ }, 1000);
+ };
+
+ const focusOtpInput = (index) => {
+ if (otpInputRefs.length === 0) {
+ otpInputRefs = Array.from(document.querySelectorAll('.otp-input'));
+ }
+ if (otpInputRefs[index]) {
+ otpInputRefs[index].focus();
+ }
+ };
+
+ const handleOtpInput = (index, event) => {
+ const value = event.target.value;
+
+ // Only allow digits
+ if (!/^\d*$/.test(value)) {
+ event.target.value = otpDigits.value[index];
+ return;
+ }
+
+ // Handle paste of full code
+ if (value.length > 1) {
+ const digits = value.replace(/\D/g, '').slice(0, 5).split('');
+ digits.forEach((digit, i) => {
+ if (i < 5) otpDigits.value[i] = digit;
+ });
+ focusOtpInput(Math.min(digits.length, 4));
+
+ // Auto-submit if complete
+ if (otpDigits.value.join('').length === 5) {
+ handleVerify2FA();
+ }
+ return;
+ }
+
+ otpDigits.value[index] = value;
+
+ // Move to next input
+ if (value && index < 4) {
+ focusOtpInput(index + 1);
+ }
+
+ // Auto-submit when complete
+ if (otpDigits.value.join('').length === 5) {
+ handleVerify2FA();
+ }
+ };
+
+ const handleOtpKeydown = (index, event) => {
+ // Handle backspace
+ if (event.key === 'Backspace' && !otpDigits.value[index] && index > 0) {
+ focusOtpInput(index - 1);
+ }
+ };
+
+ const handleOtpPaste = (event) => {
+ event.preventDefault();
+ const pastedData = event.clipboardData.getData('text');
+ const digits = pastedData.replace(/\D/g, '').slice(0, 5).split('');
+
+ digits.forEach((digit, i) => {
+ if (i < 5) otpDigits.value[i] = digit;
+ });
+
+ focusOtpInput(Math.min(digits.length, 4));
+
+ // Auto-submit if complete
+ if (otpDigits.value.join('').length === 5) {
+ handleVerify2FA();
+ }
+ };
+
+ // Web OTP API for automatic SMS code detection (Android)
+ const startWebOTP = async () => {
+ if (!('OTPCredential' in window)) {
+ console.log('Web OTP API not supported');
+ return;
+ }
+
+ abortWebOTP();
+ otpAbortController = new AbortController();
+
+ try {
+ const otp = await navigator.credentials.get({
+ otp: { transport: ['sms'] },
+ signal: otpAbortController.signal
+ });
+
+ if (otp && otp.code) {
+ // Extract 5-digit code from SMS
+ const code = otp.code.replace(/\D/g, '').slice(0, 5);
+ if (code.length === 5) {
+ code.split('').forEach((digit, i) => {
+ otpDigits.value[i] = digit;
+ });
+ // Auto-submit
+ handleVerify2FA();
+ }
+ }
+ } catch (err) {
+ if (err.name !== 'AbortError') {
+ console.log('Web OTP error:', err);
+ }
+ }
+ };
+
+ const abortWebOTP = () => {
+ if (otpAbortController) {
+ otpAbortController.abort();
+ otpAbortController = null;
+ }
+ };
+
+ // Theme picker
+ const selectTheme = (newTheme) => {
+ emit('set-theme', newTheme);
+ showThemePicker.value = false;
+ };
+
+ // Cleanup
+ onUnmounted(() => {
+ abortWebOTP();
+ if (resendTimer) clearInterval(resendTimer);
+ });
+
+ return {
+ // Login state
+ username,
+ password,
+ rememberMe,
+ showPassword,
+
+ // 2FA state
+ show2FA,
+ otpDigits,
+ deliveryMethod,
+ maskedTarget,
+ resendCooldown,
+
+ // General state
+ error,
+ success,
+ loading,
+ showThemePicker,
+
+ // Methods
+ handleSubmit,
+ handleVerify2FA,
+ handleResend,
+ backToLogin,
+ handleOtpInput,
+ handleOtpKeydown,
+ handleOtpPaste,
+ selectTheme
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Willkommen!
+ Wähle dein bevorzugtes Farbschema.
+
+
+
+
+
+
+
+
+
+
+
+
+ 
+ 
+
+
+
+
+
+
+
+ Verifizierung
+
+
+ Code wurde gesendet an
+ {{ maskedTarget }}
+
+
+
+
+
+
+
+
+
+ Code ist 5 Minuten gültig
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/components/MainMenu.js b/public/mobile/components/MainMenu.js
new file mode 100644
index 000000000..823e5b83a
--- /dev/null
+++ b/public/mobile/components/MainMenu.js
@@ -0,0 +1,85 @@
+/**
+ * MainMenu Component
+ *
+ * Displays the main module menu for the MobileApp.
+ * Shows available modules like "Lager" that the user can access.
+ */
+
+export default {
+ name: 'MainMenu',
+ emits: ['navigate'],
+ props: {
+ user: Object
+ },
+
+ setup(props, { emit }) {
+ // Available modules
+ const modules = [
+ {
+ id: 'Workorder',
+ name: 'Aufträge',
+ icon: 'clipboard-check',
+ color: 'bg-sky-500',
+ iconColor: 'text-sky-500'
+ },
+ {
+ id: 'Lieferschein',
+ name: 'Lieferschein',
+ icon: 'document',
+ color: 'bg-purple-500',
+ iconColor: 'text-purple-500'
+ },
+ {
+ id: 'Lager',
+ name: 'Lager',
+ icon: 'warehouse',
+ color: 'bg-blue-500',
+ iconColor: 'text-blue-500'
+ }
+ ];
+
+ const openModule = (moduleId) => {
+ emit('navigate', moduleId, null);
+ };
+
+ return {
+ modules,
+ openModule
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/components/OfflineIndicator.js b/public/mobile/components/OfflineIndicator.js
new file mode 100644
index 000000000..f47d311ff
--- /dev/null
+++ b/public/mobile/components/OfflineIndicator.js
@@ -0,0 +1,211 @@
+/**
+ * Offline Indicator Component
+ *
+ * Displays network status and sync state in the header.
+ * Shows: Online, Offline, Syncing states with appropriate colors.
+ */
+
+const { ref, computed, onMounted, onUnmounted, watch } = Vue;
+
+export default {
+ name: 'OfflineIndicator',
+
+ props: {
+ // Whether offline mode is enabled in settings
+ offlineModeEnabled: {
+ type: Boolean,
+ default: false
+ },
+ // Number of pending changes
+ pendingCount: {
+ type: Number,
+ default: 0
+ },
+ // Whether sync is currently running
+ isSyncing: {
+ type: Boolean,
+ default: false
+ },
+ // Data freshness level: 'fresh', 'stale', 'old', 'unknown'
+ freshness: {
+ type: String,
+ default: 'unknown'
+ }
+ },
+
+ emits: ['sync-click'],
+
+ setup(props, { emit }) {
+ const isOnline = ref(navigator.onLine);
+
+ // Update online status
+ const updateOnlineStatus = () => {
+ isOnline.value = navigator.onLine;
+ };
+
+ onMounted(() => {
+ window.addEventListener('online', updateOnlineStatus);
+ window.addEventListener('offline', updateOnlineStatus);
+ });
+
+ onUnmounted(() => {
+ window.removeEventListener('online', updateOnlineStatus);
+ window.removeEventListener('offline', updateOnlineStatus);
+ });
+
+ // Computed display state
+ const displayState = computed(() => {
+ if (!props.offlineModeEnabled) {
+ return isOnline.value ? 'online' : 'offline-no-cache';
+ }
+
+ if (props.isSyncing) {
+ return 'syncing';
+ }
+
+ if (!isOnline.value) {
+ return 'offline';
+ }
+
+ if (props.pendingCount > 0) {
+ return 'pending';
+ }
+
+ return 'online';
+ });
+
+ // Status text
+ const statusText = computed(() => {
+ switch (displayState.value) {
+ case 'syncing':
+ return 'Synchronisiere...';
+ case 'offline':
+ return 'Offline';
+ case 'offline-no-cache':
+ return 'Keine Verbindung';
+ case 'pending':
+ return `${props.pendingCount} ausstehend`;
+ case 'online':
+ return props.offlineModeEnabled ? 'Synchronisiert' : '';
+ default:
+ return '';
+ }
+ });
+
+ // Status icon
+ const statusIcon = computed(() => {
+ switch (displayState.value) {
+ case 'syncing':
+ return 'sync';
+ case 'offline':
+ case 'offline-no-cache':
+ return 'cloud-off';
+ case 'pending':
+ return 'cloud-upload';
+ case 'online':
+ return 'cloud-check';
+ default:
+ return 'cloud';
+ }
+ });
+
+ // Status color classes
+ const statusClasses = computed(() => {
+ switch (displayState.value) {
+ case 'syncing':
+ return 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300';
+ case 'offline':
+ return 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300';
+ case 'offline-no-cache':
+ return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300';
+ case 'pending':
+ return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300';
+ case 'online':
+ return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300';
+ default:
+ return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
+ }
+ });
+
+ // Freshness indicator color
+ const freshnessColor = computed(() => {
+ switch (props.freshness) {
+ case 'fresh':
+ return 'bg-green-500';
+ case 'stale':
+ return 'bg-yellow-500';
+ case 'old':
+ return 'bg-red-500';
+ default:
+ return 'bg-gray-400';
+ }
+ });
+
+ // Click handler
+ const handleClick = () => {
+ if (isOnline.value && props.pendingCount > 0) {
+ emit('sync-click');
+ }
+ };
+
+ // Should show (only show if offline mode enabled or offline)
+ const shouldShow = computed(() => {
+ return props.offlineModeEnabled || !isOnline.value;
+ });
+
+ return {
+ isOnline,
+ displayState,
+ statusText,
+ statusIcon,
+ statusClasses,
+ freshnessColor,
+ handleClick,
+ shouldShow
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+
+
+ `
+};
diff --git a/public/mobile/components/SyncStatus.js b/public/mobile/components/SyncStatus.js
new file mode 100644
index 000000000..bef0db9cd
--- /dev/null
+++ b/public/mobile/components/SyncStatus.js
@@ -0,0 +1,225 @@
+/**
+ * Sync Status Component
+ *
+ * Shows detailed sync status including pending count,
+ * last sync time, and manual sync button.
+ */
+
+const { ref, computed } = Vue;
+
+export default {
+ name: 'SyncStatus',
+
+ props: {
+ // Number of pending operations
+ pendingOperations: {
+ type: Number,
+ default: 0
+ },
+ // Number of pending photos
+ pendingPhotos: {
+ type: Number,
+ default: 0
+ },
+ // Number of failed operations
+ failedCount: {
+ type: Number,
+ default: 0
+ },
+ // Last sync timestamp text
+ lastSyncText: {
+ type: String,
+ default: 'Nie synchronisiert'
+ },
+ // Whether sync is running
+ isSyncing: {
+ type: Boolean,
+ default: false
+ },
+ // Whether device is online
+ isOnline: {
+ type: Boolean,
+ default: true
+ },
+ // Sync progress info
+ syncProgress: {
+ type: Object,
+ default: null
+ }
+ },
+
+ emits: ['sync', 'retry-failed'],
+
+ setup(props, { emit }) {
+ // Total pending count
+ const totalPending = computed(() => {
+ return props.pendingOperations + props.pendingPhotos;
+ });
+
+ // Status summary text
+ const summaryText = computed(() => {
+ const parts = [];
+
+ if (props.pendingOperations > 0) {
+ parts.push(`${props.pendingOperations} Änderung${props.pendingOperations === 1 ? '' : 'en'}`);
+ }
+
+ if (props.pendingPhotos > 0) {
+ parts.push(`${props.pendingPhotos} Foto${props.pendingPhotos === 1 ? '' : 's'}`);
+ }
+
+ if (parts.length === 0) {
+ return 'Keine ausstehenden Änderungen';
+ }
+
+ return parts.join(', ') + ' ausstehend';
+ });
+
+ // Progress text during sync
+ const progressText = computed(() => {
+ if (!props.syncProgress) return '';
+
+ const { phase, current, total, fileName } = props.syncProgress;
+
+ if (phase === 'operations') {
+ return `Synchronisiere ${current}/${total} Änderungen...`;
+ }
+
+ if (phase === 'photos') {
+ return fileName
+ ? `Lade ${current}/${total} hoch: ${fileName}`
+ : `Lade ${current}/${total} Fotos hoch...`;
+ }
+
+ return 'Synchronisiere...';
+ });
+
+ // Handle sync button click
+ const handleSync = () => {
+ if (!props.isSyncing && props.isOnline) {
+ emit('sync');
+ }
+ };
+
+ // Handle retry failed click
+ const handleRetryFailed = () => {
+ if (!props.isSyncing) {
+ emit('retry-failed');
+ }
+ };
+
+ return {
+ totalPending,
+ summaryText,
+ progressText,
+ handleSync,
+ handleRetryFailed
+ };
+ },
+
+ template: `
+
+
+
+
+
+ Synchronisation
+
+
+
+
+ {{ isOnline ? 'Online' : 'Offline' }}
+
+
+
+
+
+
+ {{ progressText }}
+ {{ Math.round((syncProgress.current / syncProgress.total) * 100) }}%
+
+
+
+
+
+
+
+
+ Ausstehend
+
+ {{ summaryText }}
+
+
+
+
+
+ Fehlgeschlagen
+
+
+
+
+
+ Letzte Synchronisation
+ {{ lastSyncText }}
+
+
+
+
+
+
+
+
+ Änderungen werden synchronisiert, sobald Sie wieder online sind.
+
+
+ `
+};
diff --git a/public/mobile/manifest.json b/public/mobile/manifest.json
new file mode 100644
index 000000000..bb27a9f6e
--- /dev/null
+++ b/public/mobile/manifest.json
@@ -0,0 +1,38 @@
+{
+ "name": "Xinon Mobile",
+ "short_name": "Xinon",
+ "description": "Mobile-optimierte Tools für Xinon",
+ "start_url": "/MobileApp",
+ "scope": "/MobileApp",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#f1f5f9",
+ "theme_color": "#005384",
+ "icons": [
+ {
+ "src": "/assets/images/xinon-sm-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/assets/images/xinon-sm-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/assets/images/xinon-sm-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/assets/images/xinon-sm-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "categories": ["business", "productivity"]
+}
diff --git a/public/mobile/modules/lager/LagerModule.js b/public/mobile/modules/lager/LagerModule.js
new file mode 100644
index 000000000..5e25a0c35
--- /dev/null
+++ b/public/mobile/modules/lager/LagerModule.js
@@ -0,0 +1,166 @@
+/**
+ * Lager Module
+ *
+ * Main module for warehouse management.
+ * Shows submodules: Inventur (stocktake)
+ */
+
+import StocktakeList from '/mobile/modules/lager/inventur/StocktakeList.js';
+import Scanner from '/mobile/modules/lager/inventur/Scanner.js';
+import MovementForm from '/mobile/modules/lager/movement/MovementForm.js';
+
+export default {
+ name: 'LagerModule',
+ emits: ['navigate', 'toast'],
+ props: {
+ user: Object,
+ submodule: String,
+ simpleMode: Boolean
+ },
+ components: {
+ StocktakeList,
+ Scanner,
+ MovementForm
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch } = Vue;
+
+ // Submodules available in Lager
+ const submodules = [
+ {
+ id: 'Inventur',
+ name: 'Inventur',
+ icon: 'clipboard',
+ color: 'bg-green-500'
+ },
+ {
+ id: 'Movement',
+ name: 'Lagerbewegung',
+ icon: 'arrows',
+ color: 'bg-blue-500'
+ }
+ ];
+
+ // Scanner state
+ const selectedStocktake = ref(null);
+ const showScanner = ref(false);
+
+ // Current view based on submodule
+ const currentView = computed(() => {
+ if (!props.submodule) return 'menu';
+ if (props.submodule.toLowerCase() === 'inventur') {
+ return showScanner.value ? 'scanner' : 'inventur';
+ }
+ if (props.submodule.toLowerCase() === 'movement') {
+ return 'movement';
+ }
+ return 'menu';
+ });
+
+ // Watch for submodule changes
+ watch(() => props.submodule, (newVal) => {
+ if (!newVal) {
+ showScanner.value = false;
+ selectedStocktake.value = null;
+ }
+ });
+
+ const openSubmodule = (submoduleId) => {
+ emit('navigate', 'Lager', submoduleId);
+ };
+
+ const goBack = () => {
+ if (showScanner.value) {
+ showScanner.value = false;
+ selectedStocktake.value = null;
+ }
+ };
+
+ const openScanner = (stocktake) => {
+ selectedStocktake.value = stocktake;
+ showScanner.value = true;
+ };
+
+ const closeScanner = () => {
+ showScanner.value = false;
+ selectedStocktake.value = null;
+ };
+
+ const showToast = (message, type) => {
+ emit('toast', message, type);
+ };
+
+ return {
+ submodules,
+ selectedStocktake,
+ showScanner,
+ currentView,
+ openSubmodule,
+ goBack,
+ openScanner,
+ closeScanner,
+ showToast
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/inventur/Scanner.js b/public/mobile/modules/lager/inventur/Scanner.js
new file mode 100644
index 000000000..0ef5909e7
--- /dev/null
+++ b/public/mobile/modules/lager/inventur/Scanner.js
@@ -0,0 +1,402 @@
+import { createModuleApi, debounce } from '/mobile/shared/api.js';
+
+const inventurApi = createModuleApi('Lager/Inventur');
+
+export default {
+ name: 'Scanner',
+ emits: ['close', 'toast'],
+ props: {
+ stocktake: { type: Object, required: true },
+ user: { type: Object, required: true }
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
+
+ const currentTab = ref('scan');
+ const isLoading = ref(false);
+ const scanner = ref(null);
+ const isScannerActive = ref(false);
+ const scannerError = ref('');
+ const scannedArticle = ref(null);
+ const quantity = ref('1');
+ const rack = ref('');
+ const shelf = ref('');
+ const searchQuery = ref('');
+ const searchResults = ref([]);
+ const categories = ref([]);
+ const selectedCategory = ref(0);
+ const isSearching = ref(false);
+ const recentScans = ref([]);
+ const isLoadingHistory = ref(false);
+ const alreadyScannedWarning = ref(null);
+ const showKeypad = ref(false);
+
+ const canSubmit = computed(() => {
+ return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
+ });
+
+ const startScanner = async () => {
+ scannerError.value = '';
+ try {
+ scanner.value = new Html5Qrcode('qr-reader');
+ await scanner.value.start(
+ { facingMode: 'environment' },
+ { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 },
+ onScanSuccess,
+ () => {}
+ );
+ isScannerActive.value = true;
+ } catch (err) {
+ scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.';
+ }
+ };
+
+ const stopScanner = async () => {
+ if (scanner.value && isScannerActive.value) {
+ try { await scanner.value.stop(); } catch (e) {}
+ isScannerActive.value = false;
+ }
+ };
+
+ const onScanSuccess = async (decodedText) => {
+ await stopScanner();
+ await lookupArticle(decodedText);
+ };
+
+ const lookupArticle = async (code) => {
+ isLoading.value = true;
+ alreadyScannedWarning.value = null;
+
+ try {
+ const result = await inventurApi.get(`getArticle?code=${encodeURIComponent(code)}`);
+
+ if (result.success) {
+ scannedArticle.value = result.article;
+ const checkResult = await inventurApi.get(
+ `checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${result.article.id}`
+ );
+ if (checkResult.success && checkResult.alreadyScanned) {
+ alreadyScannedWarning.value = checkResult.existingItem;
+ }
+ quantity.value = '1';
+ } else {
+ emit('toast', result.message || 'Artikel nicht gefunden', 'error');
+ await startScanner();
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden des Artikels', 'error');
+ await startScanner();
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const submitScan = async (overwrite = false) => {
+ if (!canSubmit.value) return;
+ isLoading.value = true;
+
+ try {
+ const payload = {
+ stocktakeId: props.stocktake.id,
+ articleId: scannedArticle.value.id,
+ quantity: parseFloat(quantity.value),
+ rack: rack.value || null,
+ shelf: shelf.value || null,
+ overwrite: overwrite,
+ overwriteItemId: overwrite && alreadyScannedWarning.value ? alreadyScannedWarning.value.id : 0
+ };
+
+ const result = await inventurApi.post('submitScan', payload);
+
+ if (result.success) {
+ navigator.vibrate?.([100]);
+ emit('toast', result.message, 'success');
+ scannedArticle.value = null;
+ quantity.value = '1';
+ rack.value = '';
+ shelf.value = '';
+ alreadyScannedWarning.value = null;
+ await startScanner();
+ } else {
+ emit('toast', result.message || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const loadCategories = async () => {
+ const result = await inventurApi.get('getCategories');
+ if (result.success) categories.value = result.categories;
+ };
+
+ const doSearch = async () => {
+ if (searchQuery.value.length < 2 && !selectedCategory.value) {
+ searchResults.value = [];
+ return;
+ }
+ isSearching.value = true;
+ try {
+ const params = new URLSearchParams();
+ if (searchQuery.value) params.set('query', searchQuery.value);
+ if (selectedCategory.value) params.set('categoryId', selectedCategory.value);
+ const result = await inventurApi.get(`searchArticles?${params}`);
+ if (result.success) searchResults.value = result.articles;
+ } catch (e) {} finally {
+ isSearching.value = false;
+ }
+ };
+
+ const searchArticles = debounce(doSearch, 300);
+
+ const selectSearchResult = async (article) => {
+ await stopScanner();
+ scannedArticle.value = article;
+ quantity.value = '1';
+ currentTab.value = 'scan';
+
+ const checkResult = await inventurApi.get(
+ `checkAlreadyScanned?stocktakeId=${props.stocktake.id}&articleId=${article.id}`
+ );
+ if (checkResult.success && checkResult.alreadyScanned) {
+ alreadyScannedWarning.value = checkResult.existingItem;
+ }
+ };
+
+ const loadHistory = async () => {
+ isLoadingHistory.value = true;
+ try {
+ const result = await inventurApi.get(`getMyScans?stocktakeId=${props.stocktake.id}`);
+ if (result.success) recentScans.value = result.items;
+ } catch (e) {} finally {
+ isLoadingHistory.value = false;
+ }
+ };
+
+ const appendDigit = (digit) => {
+ if (digit === '.' && quantity.value.includes('.')) return;
+ if (quantity.value === '0' && digit !== '.') {
+ quantity.value = digit;
+ } else {
+ quantity.value += digit;
+ }
+ };
+
+ const deleteDigit = () => {
+ quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
+ };
+
+ const clearQuantity = () => { quantity.value = '0'; };
+
+ const handleClose = async () => {
+ await stopScanner();
+ emit('close');
+ };
+
+ const switchTab = async (tab) => {
+ currentTab.value = tab;
+ if (tab === 'scan' && !scannedArticle.value) {
+ await nextTick();
+ await startScanner();
+ } else if (tab === 'search') {
+ await stopScanner();
+ await loadCategories();
+ } else if (tab === 'history') {
+ await stopScanner();
+ await loadHistory();
+ }
+ };
+
+ const cancelScan = async () => {
+ scannedArticle.value = null;
+ alreadyScannedWarning.value = null;
+ quantity.value = '1';
+ await startScanner();
+ };
+
+ onMounted(async () => { await startScanner(); });
+ onUnmounted(async () => { await stopScanner(); });
+
+ return {
+ currentTab, isLoading, isScannerActive, scannerError,
+ scannedArticle, quantity, rack, shelf,
+ searchQuery, searchResults, categories, selectedCategory, isSearching,
+ recentScans, isLoadingHistory,
+ alreadyScannedWarning, showKeypad, canSubmit,
+ startScanner, stopScanner, submitScan, searchArticles, selectSearchResult,
+ loadHistory, appendDigit, deleteDigit, clearQuantity,
+ handleClose, switchTab, cancelScan
+ };
+ },
+
+ template: `
+
+
+ {{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scannerError }}
+
+
+ QR-Code scannen oder Artikel suchen
+
+
+
+
+
+
+
+ Bereits gescannt
+
+ Menge: {{ alreadyScannedWarning.countedQuantity }}
+ Von: {{ alreadyScannedWarning.scannedBy }}
+ Am: {{ alreadyScannedWarning.scannedAt }}
+
+
+
+
+
+
+ {{ scannedArticle.title }}
+ Art.-Nr.: {{ scannedArticle.articleNumber }}
+ Kategorie: {{ scannedArticle.categoryName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ quantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
+
+
+
+
+ {{ article.title }}
+ {{ article.articleNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scan.articleTitle }}
+ {{ scan.articleNumber }}
+
+
+ {{ scan.countedQuantity }} {{ scan.unit }}
+ {{ scan.scannedAt }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ quantity }}
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/inventur/StocktakeList.js b/public/mobile/modules/lager/inventur/StocktakeList.js
new file mode 100644
index 000000000..31f0de4c2
--- /dev/null
+++ b/public/mobile/modules/lager/inventur/StocktakeList.js
@@ -0,0 +1,136 @@
+import { createModuleApi } from '/mobile/shared/api.js';
+
+const inventurApi = createModuleApi('Lager/Inventur');
+
+export default {
+ name: 'StocktakeList',
+ emits: ['select'],
+ props: {
+ user: Object
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted } = Vue;
+
+ const stocktakes = ref([]);
+ const isLoading = ref(true);
+ const error = ref('');
+
+ const fetchStocktakes = async () => {
+ isLoading.value = true;
+ error.value = '';
+
+ try {
+ const result = await inventurApi.get('getActiveStocktakes');
+
+ if (result.success) {
+ stocktakes.value = result.stocktakes;
+ } else {
+ error.value = result.error || 'Fehler beim Laden';
+ }
+ } catch (e) {
+ error.value = 'Netzwerkfehler';
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const selectStocktake = (stocktake) => {
+ emit('select', stocktake);
+ };
+
+ onMounted(() => {
+ fetchStocktakes();
+ });
+
+ return {
+ stocktakes,
+ isLoading,
+ error,
+ fetchStocktakes,
+ selectStocktake
+ };
+ },
+
+ template: `
+
+
+
+ Aktive Inventuren
+
+
+
+
+
+
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+ Keine aktiven Inventuren
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/movement/MovementForm.js b/public/mobile/modules/lager/movement/MovementForm.js
new file mode 100644
index 000000000..949afe1bc
--- /dev/null
+++ b/public/mobile/modules/lager/movement/MovementForm.js
@@ -0,0 +1,1578 @@
+import { createModuleApi, debounce } from '/mobile/shared/api.js';
+
+const movementApi = createModuleApi('Lager/Movement');
+
+
+const BottomSheetSelect = {
+ name: 'BottomSheetSelect',
+ emits: ['update:modelValue'],
+ props: {
+ modelValue: [String, Number],
+ options: { type: Array, default: () => [] },
+ label: { type: String, default: '' },
+ placeholder: { type: String, default: 'Auswählen...' },
+ valueKey: { type: String, default: 'value' },
+ labelKey: { type: String, default: 'text' },
+ icon: { type: String, default: null },
+ position: { type: String, default: 'bottom' } // 'bottom' or 'top'
+ },
+ setup(props, { emit }) {
+ const isOpen = Vue.ref(false);
+
+ const selectedLabel = Vue.computed(() => {
+ const option = props.options.find(o =>
+ (typeof o === 'object' ? o[props.valueKey] : o) === props.modelValue
+ );
+ if (!option) return props.placeholder;
+ return typeof option === 'object' ? option[props.labelKey] : option;
+ });
+
+ const select = (option) => {
+ const value = typeof option === 'object' ? option[props.valueKey] : option;
+ emit('update:modelValue', value);
+ isOpen.value = false;
+ };
+
+ return { isOpen, selectedLabel, select };
+ },
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
+
+export default {
+ name: 'MovementForm',
+ emits: ['toast'],
+ props: {
+ user: { type: Object, required: true },
+ simpleMode: { type: Boolean, default: false }
+ },
+ components: {
+ BottomSheetSelect
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted, onUnmounted, nextTick, computed, watch } = Vue;
+
+ // ==================== CONSTANTS ====================
+ const STORAGE_KEY = 'movement_settings';
+ const LOCATION_COORDS = {
+ office: { lat: 46.99552810791587, lng: 15.7751923956463, name: 'K1 Fladnitz 150' },
+ aussenlager: { lat: 46.99909466636262, lng: 15.77571245012429, name: 'Aussenlager-Extern' }
+ };
+ const GPS_ACCURACY_THRESHOLD = 100; // meters
+
+ // ==================== CONFIG ====================
+ const locations = ref([]);
+ const movementTypes = [
+ { value: 'IN', text: 'Einbuchung', icon: 'plus', color: 'green', defaultReason: 'Warenlieferung' },
+ { value: 'OUT', text: 'Ausbuchung', icon: 'minus', color: 'red', defaultReason: 'Verbrauch' },
+ { value: 'ADJUSTMENT', text: 'Korrektur', icon: 'edit', color: 'yellow', defaultReason: 'Inventurkorrektur' }
+ ];
+ const reasonCategories = ref({});
+
+ // ==================== SELECTION STATE ====================
+ const selectedLocation = ref(null);
+ const selectedType = ref('IN');
+
+ // ==================== GPS STATE ====================
+ const detectedLocation = ref(null);
+ const gpsStatus = ref('idle'); // 'idle', 'detecting', 'detected', 'error'
+ const gpsDistance = ref(null);
+
+ // ==================== MODE TOGGLES ====================
+ const turboMode = ref(false);
+ const batchMode = ref(false);
+
+ // ==================== BATCH CART ====================
+ const cartItems = ref([]);
+ const showCart = ref(false);
+
+ // ==================== TABS ====================
+ const currentTab = ref('scan');
+
+ // ==================== LOADING ====================
+ const isLoading = ref(false);
+ const isInitialized = ref(false);
+
+ // ==================== SCANNER ====================
+ const scanner = ref(null);
+ const isScannerActive = ref(false);
+ const scannerError = ref('');
+
+ // ==================== ARTICLE ====================
+ const scannedArticle = ref(null);
+ const currentStock = ref(0);
+ const quantity = ref('1');
+ const selectedReason = ref('');
+ const note = ref('');
+
+ // ==================== SEARCH ====================
+ const searchQuery = ref('');
+ const searchResults = ref([]);
+ const isSearching = ref(false);
+
+ // ==================== HISTORY ====================
+ const recentMovements = ref([]);
+ const isLoadingHistory = ref(false);
+
+ // ==================== ORDER RECEIVING ====================
+ const pendingOrders = ref([]);
+ const isLoadingOrders = ref(false);
+ const selectedOrder = ref(null);
+ const orderPositions = ref([]);
+ const deliveryNotePhoto = ref(null);
+ const isSubmittingOrder = ref(false);
+
+ // ==================== KEYPAD ====================
+ const showKeypad = ref(false);
+ const showNote = ref(false);
+
+ // ==================== UNDO STATE ====================
+ const lastMovement = ref(null);
+ const showUndo = ref(false);
+ let undoTimeout = null;
+
+ // ==================== COMPUTED ====================
+ const canSubmit = computed(() => {
+ return scannedArticle.value &&
+ selectedLocation.value &&
+ selectedType.value &&
+ parseFloat(quantity.value) > 0 &&
+ selectedReason.value &&
+ !isLoading.value;
+ });
+
+ const typeColor = computed(() => {
+ const type = movementTypes.find(t => t.value === selectedType.value);
+ return type ? type.color : 'blue';
+ });
+
+ const reasonOptions = computed(() => {
+ const reasons = reasonCategories.value[selectedType.value];
+ if (!reasons) return [];
+ return Object.entries(reasons).map(([key, label]) => ({
+ value: key,
+ text: label
+ }));
+ });
+
+ const cartTotal = computed(() => cartItems.value.length);
+
+ // Filtered movement types (hide ADJUSTMENT in simple mode)
+ const filteredMovementTypes = computed(() => {
+ if (props.simpleMode) {
+ return movementTypes.filter(t => t.value !== 'ADJUSTMENT');
+ }
+ return movementTypes;
+ });
+
+ // GPS distance formatting and color
+ const formattedGpsDistance = computed(() => {
+ if (gpsDistance.value === null) return '';
+ if (gpsDistance.value >= 1000) {
+ return (gpsDistance.value / 1000).toFixed(1) + 'km';
+ }
+ return gpsDistance.value + 'm';
+ });
+
+ const gpsDistanceColor = computed(() => {
+ if (gpsDistance.value === null) return 'text-slate-400';
+ // Green: within 200m (auto-selected range)
+ if (gpsDistance.value <= 200) return 'text-green-500';
+ // Yellow: 200m - 500m (getting far)
+ if (gpsDistance.value <= 500) return 'text-yellow-500';
+ // Red: over 500m (probably wrong location)
+ return 'text-red-500';
+ });
+
+ // ==================== LOCALSTORAGE PERSISTENCE ====================
+ const saveSettings = () => {
+ const settings = {
+ locationId: selectedLocation.value,
+ type: selectedType.value,
+ turboMode: turboMode.value,
+ batchMode: batchMode.value
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+ };
+
+ const loadSettings = () => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ return JSON.parse(saved);
+ }
+ } catch (e) {}
+ return null;
+ };
+
+ // Load settings IMMEDIATELY (synchronously) before anything else
+ const savedSettings = loadSettings();
+ if (savedSettings) {
+ if (savedSettings.type) selectedType.value = savedSettings.type;
+ if (savedSettings.turboMode !== undefined) turboMode.value = savedSettings.turboMode;
+ if (savedSettings.batchMode !== undefined) batchMode.value = savedSettings.batchMode;
+ // locationId will be applied after locations are loaded
+ }
+
+ // ==================== GPS DETECTION ====================
+ const calculateDistance = (lat1, lng1, lat2, lng2) => {
+ const R = 6371e3; // Earth's radius in meters
+ const φ1 = lat1 * Math.PI / 180;
+ const φ2 = lat2 * Math.PI / 180;
+ const Δφ = (lat2 - lat1) * Math.PI / 180;
+ const Δλ = (lng2 - lng1) * Math.PI / 180;
+ const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
+ Math.cos(φ1) * Math.cos(φ2) *
+ Math.sin(Δλ/2) * Math.sin(Δλ/2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
+ return R * c;
+ };
+
+ const detectLocation = () => {
+ if (!navigator.geolocation) {
+ gpsStatus.value = 'error';
+ return;
+ }
+
+ gpsStatus.value = 'detecting';
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ const { latitude, longitude, accuracy } = position.coords;
+
+ // Calculate distances to both locations
+ const distToOffice = calculateDistance(latitude, longitude, LOCATION_COORDS.office.lat, LOCATION_COORDS.office.lng);
+ const distToAussen = calculateDistance(latitude, longitude, LOCATION_COORDS.aussenlager.lat, LOCATION_COORDS.aussenlager.lng);
+
+ // Find closest location
+ const closest = distToOffice < distToAussen
+ ? { name: 'office', distance: distToOffice }
+ : { name: 'aussenlager', distance: distToAussen };
+
+ gpsDistance.value = Math.round(closest.distance);
+
+ // Only auto-select if accuracy is good and we're reasonably close
+ if (accuracy <= GPS_ACCURACY_THRESHOLD && closest.distance < 500) {
+ // Find matching location in our locations list
+ const matchingLoc = locations.value.find(loc =>
+ loc.title.toLowerCase() === LOCATION_COORDS[closest.name].name.toLowerCase()
+ );
+ if (matchingLoc) {
+ detectedLocation.value = matchingLoc.id;
+ // Only auto-set if user hasn't saved a preference
+ if (!savedSettings?.locationId) {
+ selectedLocation.value = matchingLoc.id;
+ }
+ gpsStatus.value = 'detected';
+ }
+ } else {
+ gpsStatus.value = 'detected';
+ }
+ },
+ (error) => {
+ gpsStatus.value = 'error';
+ },
+ { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
+ );
+ };
+
+ // ==================== QUICK ACTIONS ====================
+ const quickAction = async (type, reason) => {
+ selectedType.value = type;
+ await nextTick();
+ selectedReason.value = reason;
+ saveSettings();
+ currentTab.value = 'scan';
+ if (!scannedArticle.value) {
+ await startScanner();
+ }
+ };
+
+ // ==================== LOAD INITIAL DATA ====================
+ const loadInitialData = async () => {
+ try {
+ const [locResult, reasonResult] = await Promise.all([
+ movementApi.get('getLocations'),
+ movementApi.get('getReasonCategories')
+ ]);
+
+ if (locResult.success) {
+ locations.value = locResult.locations;
+
+ // Try to restore saved location (from already-loaded settings), otherwise use first
+ if (savedSettings?.locationId && locations.value.find(l => l.id === savedSettings.locationId)) {
+ selectedLocation.value = savedSettings.locationId;
+ } else if (locations.value.length > 0) {
+ selectedLocation.value = locations.value[0].id;
+ }
+
+ // Start GPS detection after locations are loaded
+ detectLocation();
+ }
+
+ if (reasonResult.success) {
+ reasonCategories.value = reasonResult.categories;
+ updateReasonOptions();
+ }
+
+ isInitialized.value = true;
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden der Konfiguration', 'error');
+ }
+ };
+
+ // ==================== REASON OPTIONS ====================
+ const updateReasonOptions = () => {
+ const reasons = reasonCategories.value?.[selectedType.value];
+ if (reasons && typeof reasons === 'object') {
+ const keys = Object.keys(reasons);
+ if (keys.length > 0) {
+ selectedReason.value = keys[0];
+ }
+ } else {
+ // Fallback defaults
+ const defaults = { 'IN': 'Warenlieferung', 'OUT': 'Verbrauch', 'ADJUSTMENT': 'Inventurkorrektur' };
+ if (defaults[selectedType.value]) {
+ selectedReason.value = defaults[selectedType.value];
+ }
+ }
+ };
+
+ // ==================== WATCHERS ====================
+ watch(selectedType, () => {
+ updateReasonOptions();
+ saveSettings();
+ });
+
+ watch(selectedLocation, () => {
+ saveSettings();
+ if (scannedArticle.value) {
+ loadCurrentStock();
+ }
+ });
+
+ // Ensure location is always selected when locations are loaded
+ watch(locations, (newLocations) => {
+ if (newLocations.length > 0 && !selectedLocation.value) {
+ // Try saved settings first
+ if (savedSettings?.locationId && newLocations.find(l => l.id === savedSettings.locationId)) {
+ selectedLocation.value = savedSettings.locationId;
+ } else {
+ selectedLocation.value = newLocations[0].id;
+ }
+ }
+ }, { immediate: true });
+
+ watch(() => props.simpleMode, (newVal) => {
+ if (newVal) {
+ // Reset to IN/OUT if ADJUSTMENT was selected
+ if (selectedType.value === 'ADJUSTMENT') {
+ selectedType.value = 'OUT';
+ }
+ // Switch away from history tab
+ if (currentTab.value === 'history') {
+ currentTab.value = 'scan';
+ }
+ // Disable turbo/batch modes in simple mode
+ turboMode.value = false;
+ batchMode.value = false;
+ }
+ });
+
+ watch(turboMode, () => {
+ saveSettings();
+ });
+
+ watch(batchMode, () => {
+ saveSettings();
+ });
+
+ // Also update reason when categories are loaded
+ watch(reasonCategories, () => {
+ updateReasonOptions();
+ }, { deep: true });
+
+ // ==================== SCANNER FUNCTIONS ====================
+ let scannerRestarting = false;
+
+ const startScanner = async (delay = 0) => {
+ if (scannerRestarting) return;
+ scannerError.value = '';
+
+ // Add delay for turbo mode restarts to let camera fully release
+ if (delay > 0) {
+ scannerRestarting = true;
+ await new Promise(resolve => setTimeout(resolve, delay));
+ scannerRestarting = false;
+ }
+
+ try {
+ // Ensure previous instance is cleared
+ if (scanner.value) {
+ try { await scanner.value.stop(); } catch (e) {}
+ scanner.value = null;
+ }
+ await nextTick();
+
+ scanner.value = new Html5Qrcode('qr-reader-movement');
+ await scanner.value.start(
+ { facingMode: 'environment' },
+ { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0 },
+ onScanSuccess,
+ () => {}
+ );
+ isScannerActive.value = true;
+ } catch (err) {
+ scannerError.value = 'Kamera konnte nicht gestartet werden. Bitte Berechtigungen prüfen.';
+ }
+ };
+
+ const stopScanner = async () => {
+ if (scanner.value && isScannerActive.value) {
+ try { await scanner.value.stop(); } catch (e) {}
+ isScannerActive.value = false;
+ }
+ };
+
+ const onScanSuccess = async (decodedText) => {
+ await stopScanner();
+ await lookupArticle(decodedText);
+ };
+
+ // Article lookup
+ const lookupArticle = async (code) => {
+ isLoading.value = true;
+
+ try {
+ const result = await movementApi.get(`getArticle?code=${encodeURIComponent(code)}`);
+
+ if (result.success) {
+ scannedArticle.value = result.article;
+ await loadCurrentStock();
+ quantity.value = '1';
+
+ // Ensure reason is set
+ if (!selectedReason.value) {
+ updateReasonOptions();
+ }
+
+ // TURBO MODE: Auto-submit with qty=1 and default reason
+ if (turboMode.value && !batchMode.value) {
+ await turboSubmit(result.article);
+ return;
+ }
+
+ // BATCH MODE: Add to cart and continue scanning
+ if (batchMode.value) {
+ addToCart(result.article);
+ return;
+ }
+ } else {
+ emit('toast', result.message || 'Artikel nicht gefunden', 'error');
+ await startScanner();
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden des Artikels', 'error');
+ await startScanner();
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ // ==================== TURBO MODE ====================
+ const turboSubmit = async (article) => {
+ const typeConfig = movementTypes.find(t => t.value === selectedType.value);
+ const defaultReason = typeConfig?.defaultReason || selectedReason.value;
+
+ try {
+ const payload = {
+ movementType: selectedType.value,
+ articleId: article.id,
+ locationId: selectedLocation.value,
+ quantity: 1,
+ reasonCategory: defaultReason,
+ note: null
+ };
+
+ const result = await movementApi.post('submitMovement', payload);
+
+ if (result.success) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100]);
+
+ // Store for undo
+ lastMovement.value = result.movement;
+ showUndo.value = true;
+ if (undoTimeout) clearTimeout(undoTimeout);
+ undoTimeout = setTimeout(() => { showUndo.value = false; }, 5000);
+
+ // Show quick toast
+ const typeLabel = selectedType.value === 'IN' ? '+' : selectedType.value === 'OUT' ? '-' : '±';
+ emit('toast', `${typeLabel}1 ${article.title}`, 'success');
+
+ // Reset and restart scanner with delay for camera to release
+ scannedArticle.value = null;
+ currentStock.value = 0;
+ await startScanner(300);
+ } else {
+ emit('toast', result.message || 'Fehler', 'error');
+ // Fall back to normal mode
+ scannedArticle.value = article;
+ }
+ } catch (e) {
+ emit('toast', 'Netzwerkfehler', 'error');
+ scannedArticle.value = article;
+ }
+ };
+
+ // ==================== BATCH/CART MODE ====================
+ const addToCart = (article) => {
+ // Check if already in cart
+ const existing = cartItems.value.find(item => item.article.id === article.id);
+ if (existing) {
+ existing.quantity += 1;
+ emit('toast', `${article.title} (${existing.quantity}x)`, 'success');
+ } else {
+ cartItems.value.push({
+ article: article,
+ quantity: 1,
+ stock: currentStock.value
+ });
+ emit('toast', `+ ${article.title}`, 'success');
+ }
+
+ // Reset and restart scanner with small delay
+ scannedArticle.value = null;
+ currentStock.value = 0;
+ startScanner(200);
+ };
+
+ const updateCartQuantity = (index, qty) => {
+ if (qty <= 0) {
+ cartItems.value.splice(index, 1);
+ } else {
+ cartItems.value[index].quantity = qty;
+ }
+ };
+
+ const removeFromCart = (index) => {
+ cartItems.value.splice(index, 1);
+ };
+
+ const clearCart = () => {
+ cartItems.value = [];
+ showCart.value = false;
+ };
+
+ const submitCart = async () => {
+ if (cartItems.value.length === 0) return;
+ isLoading.value = true;
+
+ const typeConfig = movementTypes.find(t => t.value === selectedType.value);
+ const defaultReason = typeConfig?.defaultReason || selectedReason.value;
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const item of cartItems.value) {
+ try {
+ const payload = {
+ movementType: selectedType.value,
+ articleId: item.article.id,
+ locationId: selectedLocation.value,
+ quantity: item.quantity,
+ reasonCategory: defaultReason,
+ note: null
+ };
+
+ const result = await movementApi.post('submitMovement', payload);
+ if (result.success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ } catch (e) {
+ errorCount++;
+ }
+ }
+
+ isLoading.value = false;
+
+ if (errorCount === 0) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100, 50, 100]);
+ emit('toast', `${successCount} Bewegungen erfolgreich`, 'success');
+ clearCart();
+ } else {
+ emit('toast', `${successCount} OK, ${errorCount} Fehler`, 'error');
+ }
+ };
+
+ // Load current stock for article at location
+ const loadCurrentStock = async () => {
+ if (!scannedArticle.value || !selectedLocation.value) {
+ currentStock.value = 0;
+ return;
+ }
+
+ try {
+ const result = await movementApi.get(
+ `getCurrentStock?articleId=${scannedArticle.value.id}&locationId=${selectedLocation.value}`
+ );
+ currentStock.value = result.success ? result.currentStock : 0;
+ } catch (e) {
+ currentStock.value = 0;
+ }
+ };
+
+ // Submit movement
+ const submitMovement = async () => {
+ if (!canSubmit.value) return;
+ isLoading.value = true;
+
+ try {
+ const payload = {
+ movementType: selectedType.value,
+ articleId: scannedArticle.value.id,
+ locationId: selectedLocation.value,
+ quantity: parseFloat(quantity.value),
+ reasonCategory: selectedReason.value,
+ note: note.value || null
+ };
+
+ const result = await movementApi.post('submitMovement', payload);
+
+ if (result.success) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100]);
+ emit('toast', result.message, 'success');
+ // Reset form
+ scannedArticle.value = null;
+ currentStock.value = 0;
+ quantity.value = '1';
+ note.value = '';
+ showNote.value = false;
+ await startScanner();
+ } else {
+ emit('toast', result.message || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const doSearch = async () => {
+ if (searchQuery.value.length < 2) {
+ searchResults.value = [];
+ return;
+ }
+ isSearching.value = true;
+ try {
+ const result = await movementApi.get(`searchArticles?query=${encodeURIComponent(searchQuery.value)}`);
+ if (result.success) searchResults.value = result.articles;
+ } catch (e) {} finally {
+ isSearching.value = false;
+ }
+ };
+
+ const searchArticles = debounce(doSearch, 300);
+
+ const selectSearchResult = async (article) => {
+ await stopScanner();
+ scannedArticle.value = article;
+ await loadCurrentStock();
+ quantity.value = '1';
+ showNote.value = false;
+ // Ensure reason is set
+ if (!selectedReason.value) {
+ updateReasonOptions();
+ }
+ currentTab.value = 'scan';
+ };
+
+ // History
+ const loadHistory = async () => {
+ isLoadingHistory.value = true;
+ try {
+ const params = selectedLocation.value ? `?locationId=${selectedLocation.value}` : '';
+ const result = await movementApi.get(`getMyMovements${params}`);
+ if (result.success) recentMovements.value = result.movements;
+ } catch (e) {} finally {
+ isLoadingHistory.value = false;
+ }
+ };
+
+ // ==================== ORDER RECEIVING FUNCTIONS ====================
+ const loadPendingOrders = async () => {
+ isLoadingOrders.value = true;
+ try {
+ const result = await movementApi.get('getPendingOrders');
+ if (result.success) {
+ pendingOrders.value = result.orders;
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden der Bestellungen', 'error');
+ } finally {
+ isLoadingOrders.value = false;
+ }
+ };
+
+ const selectOrderForReceiving = async (order) => {
+ isLoadingOrders.value = true;
+ try {
+ const result = await movementApi.get(`getOrderForReceiving?orderId=${order.id}`);
+ if (result.success) {
+ selectedOrder.value = result.order;
+ orderPositions.value = result.positions;
+ deliveryNotePhoto.value = null;
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden der Bestellung', 'error');
+ } finally {
+ isLoadingOrders.value = false;
+ }
+ };
+
+ const cancelOrderReceiving = () => {
+ selectedOrder.value = null;
+ orderPositions.value = [];
+ deliveryNotePhoto.value = null;
+ };
+
+ const submitOrderReceiving = async () => {
+ if (!selectedOrder.value || !selectedLocation.value) return;
+
+ // Collect positions with quantity > 0
+ const positionsToSubmit = orderPositions.value
+ .filter(p => p.receivingQty > 0)
+ .map(p => ({
+ articleId: p.articleId,
+ quantity: p.receivingQty
+ }));
+
+ if (positionsToSubmit.length === 0) {
+ emit('toast', 'Bitte mindestens eine Menge eingeben', 'error');
+ return;
+ }
+
+ isSubmittingOrder.value = true;
+ try {
+ const result = await movementApi.post('submitOrderReceiving', {
+ orderId: selectedOrder.value.id,
+ locationId: selectedLocation.value,
+ positions: positionsToSubmit,
+ deliveryNoteFileId: deliveryNotePhoto.value,
+ note: null
+ });
+
+ if (result.success) {
+ navigator.vibrate?.([100, 50, 100]);
+ emit('toast', result.message, 'success');
+ // Reset and reload orders
+ cancelOrderReceiving();
+ await loadPendingOrders();
+ } else {
+ emit('toast', result.message || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ isSubmittingOrder.value = false;
+ }
+ };
+
+ const setAllReceivingQty = () => {
+ orderPositions.value.forEach(p => {
+ p.receivingQty = p.remainingQty;
+ });
+ };
+
+ const clearAllReceivingQty = () => {
+ orderPositions.value.forEach(p => {
+ p.receivingQty = 0;
+ });
+ };
+
+ // Keypad
+ const appendDigit = (digit) => {
+ if (digit === '.' && quantity.value.includes('.')) return;
+ if (quantity.value === '0' && digit !== '.') {
+ quantity.value = digit;
+ } else {
+ quantity.value += digit;
+ }
+ };
+
+ const deleteDigit = () => {
+ quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
+ };
+
+ const clearQuantity = () => { quantity.value = '0'; };
+
+ // Navigation
+ const switchTab = async (tab) => {
+ currentTab.value = tab;
+ if (tab === 'scan' && !scannedArticle.value) {
+ await nextTick();
+ await startScanner();
+ } else if (tab === 'search') {
+ await stopScanner();
+ } else if (tab === 'history') {
+ await stopScanner();
+ await loadHistory();
+ } else if (tab === 'orders') {
+ await stopScanner();
+ await loadPendingOrders();
+ }
+ };
+
+ const cancelScan = async () => {
+ scannedArticle.value = null;
+ currentStock.value = 0;
+ quantity.value = '1';
+ note.value = '';
+ showNote.value = false;
+ await startScanner();
+ };
+
+ // Get type badge classes
+ const getTypeBadgeClass = (type) => {
+ const colors = {
+ 'IN': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
+ 'OUT': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
+ 'ADJUSTMENT': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
+ };
+ return colors[type] || 'bg-slate-100 text-slate-800';
+ };
+
+ onMounted(async () => {
+ await loadInitialData();
+ await startScanner();
+ });
+
+ onUnmounted(async () => {
+ await stopScanner();
+ });
+
+ return {
+ // Config
+ locations, movementTypes, reasonCategories, reasonOptions,
+ selectedLocation, selectedType,
+ // GPS
+ detectedLocation, gpsStatus, gpsDistance, detectLocation, formattedGpsDistance, gpsDistanceColor,
+ // Mode toggles
+ turboMode, batchMode, filteredMovementTypes,
+ // Cart
+ cartItems, cartTotal, showCart,
+ addToCart, updateCartQuantity, removeFromCart, clearCart, submitCart,
+ // Quick actions
+ quickAction,
+ // Undo
+ lastMovement, showUndo,
+ // Tabs & Loading
+ currentTab, isLoading, isInitialized,
+ isScannerActive, scannerError,
+ // Article
+ scannedArticle, currentStock, quantity, selectedReason, note,
+ // Search
+ searchQuery, searchResults, isSearching,
+ // History
+ recentMovements, isLoadingHistory,
+ // Order Receiving
+ pendingOrders, isLoadingOrders, selectedOrder, orderPositions,
+ deliveryNotePhoto, isSubmittingOrder,
+ loadPendingOrders, selectOrderForReceiving, cancelOrderReceiving,
+ submitOrderReceiving, setAllReceivingQty, clearAllReceivingQty,
+ // UI
+ showKeypad, showNote, canSubmit, typeColor,
+ // Functions
+ startScanner, stopScanner, submitMovement,
+ searchArticles, selectSearchResult, loadHistory,
+ appendDigit, deleteDigit, clearQuantity,
+ switchTab, cancelScan, getTypeBadgeClass
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+ {{ formattedGpsDistance }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ scannerError }}
+
+
+ QR-Code scannen oder Artikel suchen
+
+
+
+
+
+
+
+
+ {{ scannedArticle.title }}
+ {{ scannedArticle.articleNumber }}
+
+
+
+ {{ currentStock }}
+
+ {{ scannedArticle.unit || 'Stk.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ searchQuery.length < 2 ? 'Mindestens 2 Zeichen eingeben' : 'Keine Artikel gefunden' }}
+
+
+
+
+ {{ article.title }}
+ {{ article.articleNumber }}
+
+
+
+
+
+
+
+
+
+ Noch keine Bewegungen
+
+
+
+
+
+
+
+
+ {{ movement.movementType === 'IN' ? 'Einbuchung' : movement.movementType === 'OUT' ? 'Ausbuchung' : 'Korrektur' }}
+
+ {{ movement.locationTitle }}
+
+ {{ movement.articleTitle }}
+ {{ movement.articleNumber }}
+
+
+
+ {{ movement.movementType === 'IN' ? '+' : movement.movementType === 'OUT' ? '-' : '' }}{{ movement.quantity }} {{ movement.unit }}
+
+ {{ movement.create }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine offenen Bestellungen
+ Alle Lieferungen wurden empfangen
+
+
+
+ {{ pendingOrders.length }} Bestellung(en) warten auf Wareneingang:
+
+
+
+
+ {{ order.orderNumber }}
+
+ {{ order.statusLabel }}
+
+
+ {{ order.distributorName }}
+
+ {{ order.positionCount }} Position(en) · {{ order.totalItems }} Artikel gesamt
+
+
+
+ {{ order.create }}
+
+ {{ order.daysSinceSent }} Tag(e)
+
+
+
+
+
+ Wareneingang erfassen
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedOrder.orderNumber }}
+ {{ selectedOrder.distributorName }}
+
+
+
+ Bestellt: {{ selectedOrder.create }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pos.articleTitle }}
+ {{ pos.articleNumber }}
+
+
+ Bestellt: {{ pos.orderedQty }}
+ Erhalten: {{ pos.deliveredQty }}
+
+
+
+ Empfangen:
+
+
+
+
+
+ / {{ pos.remainingQty }} {{ pos.unit }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ quantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sammelkorb ({{ cartTotal }})
+
+
+
+
+
+
+
+ Korb ist leer
+
+
+
+ {{ item.article.title }}
+ {{ item.article.articleNumber }}
+
+
+
+
+ {{ item.quantity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TURBO: Scan = Sofort buchen (1x)
+
+
+
+
+
+
+
+ {{ lastMovement?.articleTitle }} gebucht
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/DatePicker.js b/public/mobile/modules/lager/shippingnote/DatePicker.js
new file mode 100644
index 000000000..325f50704
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/DatePicker.js
@@ -0,0 +1,282 @@
+/**
+ * DatePicker Component
+ *
+ * Beautiful mobile date picker with bottom sheet modal.
+ * Features quick buttons (Heute, Gestern) and calendar grid.
+ */
+
+export default {
+ name: 'DatePicker',
+ emits: ['update:modelValue', 'close'],
+ props: {
+ modelValue: {
+ type: String,
+ default: null
+ },
+ show: {
+ type: Boolean,
+ default: false
+ }
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch } = Vue;
+
+ // Current calendar view month/year
+ const viewDate = ref(new Date());
+
+ // Initialize view date when opened
+ watch(() => props.show, (newVal) => {
+ if (newVal && props.modelValue) {
+ viewDate.value = new Date(props.modelValue);
+ } else if (newVal) {
+ viewDate.value = new Date();
+ }
+ });
+
+ // German weekday names (short)
+ const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
+
+ // German month names
+ const monthNames = [
+ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
+ ];
+
+ const shortMonthNames = [
+ 'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'
+ ];
+
+ const weekDayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+
+ // Format date for display
+ const formatDisplayDate = (dateStr) => {
+ if (!dateStr) return 'Datum wählen';
+ const date = new Date(dateStr);
+ const dayName = weekDayNames[date.getDay()];
+ const day = date.getDate();
+ const month = shortMonthNames[date.getMonth()];
+ const year = date.getFullYear();
+ return `${dayName}, ${day}. ${month} ${year}`;
+ };
+
+ // Current month/year display
+ const currentMonthYear = computed(() => {
+ const month = monthNames[viewDate.value.getMonth()];
+ const year = viewDate.value.getFullYear();
+ return `${month} ${year}`;
+ });
+
+ // Get calendar days for current view
+ const calendarDays = computed(() => {
+ const year = viewDate.value.getFullYear();
+ const month = viewDate.value.getMonth();
+
+ // First day of month
+ const firstDay = new Date(year, month, 1);
+ // Last day of month
+ const lastDay = new Date(year, month + 1, 0);
+
+ // Day of week for first day (0=Sun, convert to 0=Mon)
+ let startDay = firstDay.getDay() - 1;
+ if (startDay < 0) startDay = 6;
+
+ const days = [];
+
+ // Add empty slots for days before first of month
+ for (let i = 0; i < startDay; i++) {
+ days.push({ day: null, date: null });
+ }
+
+ // Add days of month
+ for (let d = 1; d <= lastDay.getDate(); d++) {
+ const date = new Date(year, month, d);
+ const dateStr = formatDateISO(date);
+ days.push({
+ day: d,
+ date: dateStr,
+ isToday: isToday(date),
+ isSelected: dateStr === props.modelValue
+ });
+ }
+
+ return days;
+ });
+
+ // Check if date is today
+ const isToday = (date) => {
+ const today = new Date();
+ return date.toDateString() === today.toDateString();
+ };
+
+ // Format date as ISO string (YYYY-MM-DD)
+ const formatDateISO = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // Quick date helpers
+ const getToday = () => formatDateISO(new Date());
+ const getYesterday = () => {
+ const d = new Date();
+ d.setDate(d.getDate() - 1);
+ return formatDateISO(d);
+ };
+
+ // Navigation
+ const prevMonth = () => {
+ viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1);
+ };
+
+ const nextMonth = () => {
+ viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1);
+ };
+
+ // Selection
+ const selectDate = (dateStr) => {
+ if (!dateStr) return;
+ emit('update:modelValue', dateStr);
+ emit('close');
+ };
+
+ const selectToday = () => selectDate(getToday());
+ const selectYesterday = () => selectDate(getYesterday());
+
+ const close = () => {
+ emit('close');
+ };
+
+ return {
+ viewDate,
+ weekDays,
+ currentMonthYear,
+ calendarDays,
+ formatDisplayDate,
+ prevMonth,
+ nextMonth,
+ selectDate,
+ selectToday,
+ selectYesterday,
+ close,
+ getToday,
+ getYesterday
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Datum wählen
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentMonthYear }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/EmployeeSelector.js b/public/mobile/modules/lager/shippingnote/EmployeeSelector.js
new file mode 100644
index 000000000..df440c853
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/EmployeeSelector.js
@@ -0,0 +1,189 @@
+/**
+ * EmployeeSelector Component
+ *
+ * Bottom sheet modal for searching and selecting employees.
+ * Supports lazy word search (e.g., "fab her" matches "Fabian Herbst").
+ */
+
+export default {
+ name: 'EmployeeSelector',
+ emits: ['select', 'close'],
+ props: {
+ show: {
+ type: Boolean,
+ default: false
+ },
+ excludeIds: {
+ type: Array,
+ default: () => []
+ }
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch } = Vue;
+
+ const searchQuery = ref('');
+ const employees = ref([]);
+ const isLoading = ref(false);
+ const searchTimeout = ref(null);
+
+ // Filter out already selected employees
+ const filteredEmployees = computed(() => {
+ return employees.value.filter(emp => !props.excludeIds.includes(emp.id));
+ });
+
+ // Search employees when query changes
+ watch(searchQuery, (newVal) => {
+ if (searchTimeout.value) {
+ clearTimeout(searchTimeout.value);
+ }
+
+ // Debounce search
+ searchTimeout.value = setTimeout(() => {
+ searchEmployees(newVal);
+ }, 300);
+ });
+
+ // Load employees when modal opens
+ watch(() => props.show, (newVal) => {
+ if (newVal) {
+ searchQuery.value = '';
+ searchEmployees('');
+ }
+ });
+
+ const searchEmployees = async (query) => {
+ isLoading.value = true;
+ try {
+ const params = new URLSearchParams();
+ if (query) params.append('query', query);
+
+ const response = await fetch(`/MobileApp/Lager/ShippingNote/searchEmployees?${params}`, {
+ credentials: 'include'
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ employees.value = data.employees;
+ }
+ } catch (error) {
+ console.error('Error searching employees:', error);
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const selectEmployee = (employee) => {
+ emit('select', employee);
+ emit('close');
+ };
+
+ const close = () => {
+ emit('close');
+ };
+
+ return {
+ searchQuery,
+ filteredEmployees,
+ isLoading,
+ selectEmployee,
+ close
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mitarbeiter auswählen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Mitarbeiter gefunden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/SearchSelectModal.js b/public/mobile/modules/lager/shippingnote/SearchSelectModal.js
new file mode 100644
index 000000000..ecb8c9a17
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/SearchSelectModal.js
@@ -0,0 +1,254 @@
+/**
+ * SearchSelectModal Component
+ *
+ * Reusable bottom sheet modal for searching and selecting items.
+ * Works correctly with iPhone Dynamic Island by using bottom sheet pattern.
+ *
+ * Usage:
+ *
+ *
+ * {{ item.name }}
+ *
+ *
+ */
+
+export default {
+ name: 'SearchSelectModal',
+ emits: ['select', 'close', 'update:modelValue'],
+ props: {
+ show: {
+ type: Boolean,
+ default: false
+ },
+ title: {
+ type: String,
+ default: 'Auswählen'
+ },
+ searchable: {
+ type: Boolean,
+ default: false
+ },
+ searchPlaceholder: {
+ type: String,
+ default: 'Suchen...'
+ },
+ modelValue: {
+ type: String,
+ default: ''
+ },
+ items: {
+ type: Array,
+ default: () => []
+ },
+ loading: {
+ type: Boolean,
+ default: false
+ },
+ emptyText: {
+ type: String,
+ default: 'Keine Ergebnisse gefunden'
+ },
+ emptyIcon: {
+ type: String,
+ default: 'search' // search, users, box, list
+ },
+ selectedId: {
+ type: [String, Number],
+ default: null
+ },
+ itemKey: {
+ type: String,
+ default: 'id'
+ },
+ showNoneOption: {
+ type: Boolean,
+ default: false
+ },
+ noneOptionText: {
+ type: String,
+ default: 'Keine Auswahl'
+ }
+ },
+
+ setup(props, { emit, slots }) {
+ const { ref, computed, watch } = Vue;
+
+ const searchInput = ref(null);
+
+ // Focus search input when modal opens
+ watch(() => props.show, (newVal) => {
+ if (newVal && props.searchable) {
+ setTimeout(() => {
+ searchInput.value?.focus();
+ }, 100);
+ }
+ });
+
+ const updateSearch = (event) => {
+ emit('update:modelValue', event.target.value);
+ };
+
+ const selectItem = (item) => {
+ emit('select', item);
+ };
+
+ const selectNone = () => {
+ emit('select', null);
+ };
+
+ const close = () => {
+ emit('close');
+ };
+
+ const isSelected = (item) => {
+ if (props.selectedId === null || props.selectedId === undefined) return false;
+ return item[props.itemKey] === props.selectedId;
+ };
+
+ // Icons for empty states
+ const emptyIcons = {
+ search: ` `,
+ users: ` `,
+ box: ` `,
+ list: ` `
+ };
+
+ const getEmptyIcon = computed(() => {
+ return emptyIcons[props.emptyIcon] || emptyIcons.search;
+ });
+
+ return {
+ searchInput,
+ updateSearch,
+ selectItem,
+ selectNone,
+ close,
+ isSelected,
+ getEmptyIcon
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js b/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
new file mode 100644
index 000000000..c0735ea9a
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
@@ -0,0 +1,1075 @@
+/**
+ * ShippingNote Form Component - Redesigned
+ *
+ * Features:
+ * - GPS-based customer auto-detection with smart collapsed card
+ * - Multi-employee support with individual hours
+ * - Custom date picker
+ * - Reorganized field layout by importance
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+import DatePicker from '/mobile/modules/lager/shippingnote/DatePicker.js';
+import EmployeeSelector from '/mobile/modules/lager/shippingnote/EmployeeSelector.js';
+import SearchSelectModal from '/mobile/modules/lager/shippingnote/SearchSelectModal.js';
+
+export default {
+ name: 'ShippingNoteForm',
+ emits: ['created', 'createAndSign', 'toast'],
+ props: {
+ user: Object
+ },
+ components: {
+ DatePicker,
+ EmployeeSelector,
+ SearchSelectModal
+ },
+
+ setup(props, { emit }) {
+ const { ref, reactive, computed, onMounted, watch } = Vue;
+
+ // GPS detection state
+ const gpsState = ref('detecting'); // detecting | customer_found | no_customer | manual | error
+ const gpsPosition = ref(null);
+ const detectedCustomer = ref(null);
+ const gpsAddress = ref(null);
+ const gpsDistance = ref(null);
+
+ // Customer card state - collapsed when customer found
+ const customerCardExpanded = ref(false);
+
+ // Form data
+ const form = reactive({
+ customerId: null,
+ customerName: '',
+ billingAddressId: null,
+ deliveryAddressName: '',
+ deliveryAddressLine: '',
+ deliveryAddressPLZ: '',
+ deliveryAddressCity: '',
+ deliveryAddressEMail: '',
+ note: '',
+ type: 'V'
+ });
+
+ // Hours entries - supports multiple employees
+ const hoursEntries = ref([createNewHoursEntry()]);
+
+ // Active entry index for date picker
+ const activeEntryIndex = ref(0);
+ const showDatePicker = ref(false);
+ const showEmployeeSelector = ref(false);
+
+ // Positions
+ const positions = ref([]);
+
+ // UI state
+ const loading = ref(false);
+ const showCustomerSearch = ref(false);
+ const customerSearchQuery = ref('');
+ const customerSearchResults = ref([]);
+ const customerSearchLoading = ref(false);
+
+ const showArticleSearch = ref(false);
+ const articleSearchQuery = ref('');
+ const articleSearchResults = ref([]);
+ const articleSearchLoading = ref(false);
+
+ const showCarSelect = ref(false);
+ const carSelectEntryIndex = ref(0);
+ const allCars = ref([]);
+
+ const showHourTypeSelect = ref(false);
+ const hourTypeSelectEntryIndex = ref(0);
+ const hourTypes = ref([]);
+
+ const showPositionsSection = ref(false);
+
+ // User's car
+ const userCar = ref(null);
+
+ // Quick work type chips
+ const quickWorkTypes = ['Spleißen', 'Jetten', 'Inbetriebnahme'];
+ const selectedWorkType = ref(null);
+
+ // Select quick work type
+ const selectWorkType = (type) => {
+ if (selectedWorkType.value === type) {
+ // Deselect if already selected
+ selectedWorkType.value = null;
+ form.note = '';
+ } else {
+ selectedWorkType.value = type;
+ form.note = type;
+ }
+ navigator.vibrate?.([20]);
+ };
+
+ // Watch for manual note changes to clear chip selection
+ watch(() => form.note, (newVal) => {
+ if (newVal && !quickWorkTypes.includes(newVal)) {
+ selectedWorkType.value = null;
+ }
+ });
+
+ // Create new hours entry - default to 1h (most common)
+ function createNewHoursEntry(userId = null, userName = '') {
+ return {
+ id: Date.now() + Math.random(),
+ userId: userId || props.user?.id || null,
+ userName: userName || props.user?.name || '',
+ date: new Date().toISOString().split('T')[0],
+ hourCount: 1,
+ hourType: '',
+ hourTypeName: 'Normal',
+ carId: null,
+ carName: '',
+ kilometerCount: 0,
+ comment: '',
+ expanded: true
+ };
+ }
+
+ // Validation
+ const isValid = computed(() => {
+ return form.customerName.trim() !== '' &&
+ form.deliveryAddressLine.trim() !== '' &&
+ form.deliveryAddressCity.trim() !== '' &&
+ form.note.trim() !== '';
+ });
+
+ // Format distance for display
+ const formatDistance = (distance) => {
+ if (distance === null || distance === undefined || isNaN(distance)) {
+ return '';
+ }
+ return Math.round(distance) + 'm';
+ };
+
+ // Format date for display - German style
+ const formatDateDisplay = (dateStr) => {
+ if (!dateStr) return 'Datum wählen';
+ const date = new Date(dateStr);
+ const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+ const months = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
+ const dayName = weekDays[date.getDay()];
+ const day = date.getDate();
+ const month = months[date.getMonth()];
+ const year = date.getFullYear();
+ return `${dayName}, ${day}. ${month} ${year}`;
+ };
+
+ // Format hours display as "1h 15m"
+ const formatHoursValue = (hours) => {
+ const h = Math.floor(hours);
+ const m = Math.round((hours - h) * 60);
+ return { h, m };
+ };
+
+ // Get selected employee IDs (to exclude from selector)
+ const selectedEmployeeIds = computed(() => {
+ return hoursEntries.value.map(e => e.userId).filter(id => id !== null);
+ });
+
+ onMounted(async () => {
+ await loadInitializationData();
+ detectGPS();
+ });
+
+ const loadInitializationData = async () => {
+ try {
+ const data = await shippingNoteApi.get('initialize');
+ if (data.success) {
+ if (data.userCar) {
+ userCar.value = data.userCar;
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].carId = data.userCar.id;
+ hoursEntries.value[0].carName = data.userCar.name;
+ }
+ }
+ allCars.value = data.allCars || [];
+ hourTypes.value = data.hourTypes || [];
+ }
+ } catch (e) {
+ console.error('Failed to load initialization data:', e);
+ }
+ };
+
+ // GPS Detection
+ const detectGPS = () => {
+ gpsState.value = 'detecting';
+
+ if (!navigator.geolocation) {
+ gpsState.value = 'error';
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ async (position) => {
+ gpsPosition.value = {
+ lat: position.coords.latitude,
+ lng: position.coords.longitude
+ };
+ await findNearbyCustomer();
+ await calculateDistance();
+ },
+ (error) => {
+ console.error('GPS error:', error);
+ gpsState.value = 'error';
+ },
+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
+ );
+ };
+
+ // Find nearby customer
+ const findNearbyCustomer = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `getCustomerByLocation?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.customer) {
+ detectedCustomer.value = data.customer;
+ gpsDistance.value = data.customer.distance;
+ gpsState.value = 'customer_found';
+ fillFromCustomer(data.customer);
+ customerCardExpanded.value = false; // Collapse card when customer found
+ navigator.vibrate?.([100, 50, 100]);
+ } else {
+ gpsState.value = 'no_customer';
+ customerCardExpanded.value = true; // Expand card when no customer
+ await reverseGeocode();
+ }
+ } catch (e) {
+ console.error('Customer detection failed:', e);
+ gpsState.value = 'no_customer';
+ customerCardExpanded.value = true;
+ await reverseGeocode();
+ }
+ };
+
+ // Reverse geocode GPS position
+ const reverseGeocode = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `reverseGeocode?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.address) {
+ gpsAddress.value = data.address;
+ form.deliveryAddressLine = data.address.street || '';
+ form.deliveryAddressPLZ = data.address.zip || '';
+ form.deliveryAddressCity = data.address.city || '';
+ }
+ } catch (e) {
+ console.error('Reverse geocode failed:', e);
+ }
+ };
+
+ // Calculate distance from office
+ const calculateDistance = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `calculateDistance?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.distanceRoundTrip) {
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = Math.round(data.distanceRoundTrip);
+ }
+ }
+ } catch (e) {
+ console.error('Distance calculation failed:', e);
+ }
+ };
+
+ // Fill form from customer
+ const fillFromCustomer = (customer) => {
+ form.customerId = customer.id;
+ form.customerName = customer.displayName || customer.company || customer.name || '';
+ form.billingAddressId = customer.id;
+ form.deliveryAddressName = customer.displayName || customer.company || customer.name || '';
+ form.deliveryAddressLine = customer.street || '';
+ form.deliveryAddressPLZ = customer.zip || '';
+ form.deliveryAddressCity = customer.city || '';
+ form.deliveryAddressEMail = customer.email || '';
+ };
+
+ // Customer search
+ const searchCustomers = async () => {
+ if (customerSearchQuery.value.length < 1) {
+ customerSearchResults.value = [];
+ return;
+ }
+
+ customerSearchLoading.value = true;
+ try {
+ const data = await shippingNoteApi.get(
+ `searchCustomers?query=${encodeURIComponent(customerSearchQuery.value)}`
+ );
+ customerSearchResults.value = data.customers || [];
+ } catch (e) {
+ console.error('Customer search failed:', e);
+ } finally {
+ customerSearchLoading.value = false;
+ }
+ };
+
+ // Select customer from search
+ const selectCustomer = async (customer) => {
+ fillFromCustomer(customer);
+ gpsState.value = 'manual';
+ customerCardExpanded.value = false;
+ showCustomerSearch.value = false;
+ customerSearchQuery.value = '';
+ customerSearchResults.value = [];
+
+ // Calculate distance if customer has GPS coordinates
+ if (customer.gpsLat && customer.gpsLong) {
+ try {
+ const data = await shippingNoteApi.get(
+ `calculateDistance?lat=${customer.gpsLat}&lng=${customer.gpsLong}`
+ );
+ if (data.success && data.distanceRoundTrip) {
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = Math.round(data.distanceRoundTrip);
+ }
+ }
+ } catch (e) {
+ console.error('Distance calculation failed:', e);
+ }
+ }
+ };
+
+ // Article search
+ const searchArticles = async () => {
+ if (articleSearchQuery.value.length < 1) {
+ articleSearchResults.value = [];
+ return;
+ }
+
+ articleSearchLoading.value = true;
+ try {
+ const data = await shippingNoteApi.get(
+ `searchArticles?query=${encodeURIComponent(articleSearchQuery.value)}`
+ );
+ articleSearchResults.value = data.articles || [];
+ } catch (e) {
+ console.error('Article search failed:', e);
+ } finally {
+ articleSearchLoading.value = false;
+ }
+ };
+
+ // Add article to positions
+ const addArticle = (article) => {
+ const existing = positions.value.find(p => p.articleId === article.id);
+ if (existing) {
+ existing.amount++;
+ } else {
+ positions.value.push({
+ articleId: article.id,
+ articleNumber: article.articleNumber,
+ articleName: article.title,
+ amount: 1,
+ isEnergieMaterial: false
+ });
+ }
+ showArticleSearch.value = false;
+ articleSearchQuery.value = '';
+ articleSearchResults.value = [];
+ navigator.vibrate?.([50]);
+ };
+
+ // Remove position
+ const removePosition = (index) => {
+ positions.value.splice(index, 1);
+ };
+
+ // Toggle Beigestelltes Material
+ const toggleEnergieMaterial = (pos) => {
+ pos.isEnergieMaterial = !pos.isEnergieMaterial;
+ navigator.vibrate?.([20]);
+ };
+
+ // Hours functions for specific entry
+ const setHours = (entryIndex, hours) => {
+ if (hoursEntries.value[entryIndex]) {
+ hoursEntries.value[entryIndex].hourCount = hours;
+ navigator.vibrate?.([30]);
+ }
+ };
+
+ const adjustHours = (entryIndex, delta) => {
+ if (hoursEntries.value[entryIndex]) {
+ const newVal = Math.max(0, hoursEntries.value[entryIndex].hourCount + delta);
+ hoursEntries.value[entryIndex].hourCount = Math.round(newVal * 4) / 4;
+ navigator.vibrate?.([20]);
+ }
+ };
+
+ // Date picker
+ const openDatePicker = (entryIndex) => {
+ activeEntryIndex.value = entryIndex;
+ showDatePicker.value = true;
+ };
+
+ const updateEntryDate = (dateStr) => {
+ if (hoursEntries.value[activeEntryIndex.value]) {
+ hoursEntries.value[activeEntryIndex.value].date = dateStr;
+ }
+ };
+
+ // Employee selector
+ const openEmployeeSelector = () => {
+ showEmployeeSelector.value = true;
+ };
+
+ const addEmployee = (employee) => {
+ const newEntry = createNewHoursEntry(employee.id, employee.name);
+ // Copy date and km from first entry
+ if (hoursEntries.value[0]) {
+ newEntry.date = hoursEntries.value[0].date;
+ newEntry.kilometerCount = hoursEntries.value[0].kilometerCount;
+ }
+ hoursEntries.value.push(newEntry);
+ navigator.vibrate?.([50]);
+ };
+
+ const removeHoursEntry = (entryIndex) => {
+ if (hoursEntries.value.length > 1) {
+ hoursEntries.value.splice(entryIndex, 1);
+ navigator.vibrate?.([30]);
+ }
+ };
+
+ // Select car for specific entry
+ const openCarSelect = (entryIndex) => {
+ carSelectEntryIndex.value = entryIndex;
+ showCarSelect.value = true;
+ };
+
+ const selectCar = (car) => {
+ if (hoursEntries.value[carSelectEntryIndex.value]) {
+ hoursEntries.value[carSelectEntryIndex.value].carId = car?.id || null;
+ hoursEntries.value[carSelectEntryIndex.value].carName = car?.name || '';
+ }
+ showCarSelect.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Select hour type for specific entry
+ const openHourTypeSelect = (entryIndex) => {
+ hourTypeSelectEntryIndex.value = entryIndex;
+ showHourTypeSelect.value = true;
+ };
+
+ const selectHourType = (hourType) => {
+ if (hoursEntries.value[hourTypeSelectEntryIndex.value]) {
+ hoursEntries.value[hourTypeSelectEntryIndex.value].hourType = hourType.id;
+ hoursEntries.value[hourTypeSelectEntryIndex.value].hourTypeName = hourType.name;
+ }
+ showHourTypeSelect.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Customer card expand/collapse
+ const toggleCustomerCard = () => {
+ customerCardExpanded.value = !customerCardExpanded.value;
+ };
+
+ // Switch to manual mode
+ const switchToManual = () => {
+ gpsState.value = 'manual';
+ showCustomerSearch.value = true;
+ };
+
+ // Refresh GPS
+ const refreshGPS = () => {
+ detectGPS();
+ };
+
+ // Mock customer found (for screenshots/demo)
+ const mockCustomerFound = () => {
+ detectedCustomer.value = {
+ id: 999,
+ displayName: 'Frau im Zentrum GmbH',
+ company: 'Frau im Zentrum GmbH',
+ street: 'Schmiedgasse 14',
+ zip: '8010',
+ city: 'Graz',
+ email: 'office@frauzentrum.at'
+ };
+ gpsDistance.value = 42;
+ gpsState.value = 'customer_found';
+ fillFromCustomer(detectedCustomer.value);
+ customerCardExpanded.value = false;
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = 84;
+ }
+ navigator.vibrate?.([100, 50, 100]);
+ };
+
+ // Submit form
+ const submit = async (andSign = false) => {
+ if (!isValid.value) {
+ emit('toast', 'Bitte alle Pflichtfelder ausfüllen', 'error');
+ return;
+ }
+
+ loading.value = true;
+
+ try {
+ const payload = {
+ billingAddressId: form.billingAddressId,
+ deliveryAddressName: form.deliveryAddressName,
+ deliveryAddressLine: form.deliveryAddressLine,
+ deliveryAddressPLZ: form.deliveryAddressPLZ,
+ deliveryAddressCity: form.deliveryAddressCity,
+ deliveryAddressEMail: form.deliveryAddressEMail,
+ note: form.note,
+ type: form.type,
+ positions: positions.value.map(p => ({
+ article: p.articleId,
+ amount: p.amount,
+ isEnergieMaterial: p.isEnergieMaterial
+ })),
+ hoursEntries: hoursEntries.value.filter(h => h.hourCount > 0).map(h => ({
+ userId: h.userId,
+ date: h.date,
+ hourCount: h.hourCount,
+ hourType: h.hourType,
+ carId: h.carId,
+ kilometerCount: h.kilometerCount,
+ comment: h.comment
+ }))
+ };
+
+ const data = await shippingNoteApi.post('create', payload);
+
+ if (data.success) {
+ if (andSign) {
+ emit('createAndSign', data.shippingNote);
+ } else {
+ emit('created', data.shippingNote);
+ }
+ resetForm();
+ } else {
+ emit('toast', data.error || 'Fehler beim Erstellen', 'error');
+ }
+ } catch (e) {
+ console.error('Submit failed:', e);
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Reset form
+ const resetForm = () => {
+ form.customerId = null;
+ form.customerName = '';
+ form.billingAddressId = null;
+ form.deliveryAddressName = '';
+ form.deliveryAddressLine = '';
+ form.deliveryAddressPLZ = '';
+ form.deliveryAddressCity = '';
+ form.deliveryAddressEMail = '';
+ form.note = '';
+ positions.value = [];
+ hoursEntries.value = [createNewHoursEntry()];
+ if (userCar.value) {
+ hoursEntries.value[0].carId = userCar.value.id;
+ hoursEntries.value[0].carName = userCar.value.name;
+ }
+ detectGPS();
+ };
+
+ // Debounced search watchers
+ let customerSearchTimeout = null;
+ watch(customerSearchQuery, () => {
+ clearTimeout(customerSearchTimeout);
+ customerSearchTimeout = setTimeout(searchCustomers, 300);
+ });
+
+ let articleSearchTimeout = null;
+ watch(articleSearchQuery, () => {
+ clearTimeout(articleSearchTimeout);
+ articleSearchTimeout = setTimeout(searchArticles, 300);
+ });
+
+ return {
+ gpsState, gpsPosition, detectedCustomer, gpsAddress, gpsDistance,
+ customerCardExpanded, form, hoursEntries, positions, loading,
+ showCustomerSearch, customerSearchQuery, customerSearchResults, customerSearchLoading,
+ showArticleSearch, articleSearchQuery, articleSearchResults, articleSearchLoading,
+ showCarSelect, carSelectEntryIndex, allCars,
+ showHourTypeSelect, hourTypeSelectEntryIndex, hourTypes,
+ showPositionsSection, showDatePicker, activeEntryIndex,
+ showEmployeeSelector, selectedEmployeeIds,
+ userCar, isValid,
+ quickWorkTypes, selectedWorkType, selectWorkType,
+ formatDistance, formatDateDisplay, formatHoursValue,
+ detectGPS, refreshGPS, mockCustomerFound, switchToManual, selectCustomer,
+ toggleCustomerCard, addArticle, removePosition, toggleEnergieMaterial,
+ setHours, adjustHours, openDatePicker, updateEntryDate,
+ openEmployeeSelector, addEmployee, removeHoursEntry,
+ openCarSelect, selectCar, openHourTypeSelect, selectHourType,
+ submit, resetForm
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+ Kunde erkannt
+ {{ formatDistance(gpsDistance) }}
+
+ {{ form.customerName }}
+ {{ form.deliveryAddressLine }}, {{ form.deliveryAddressPLZ }} {{ form.deliveryAddressCity }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Standort wird ermittelt...
+
+
+
+
+ GPS Kunde erkannt
+
+
+
+
+ Kein Kunde in der Nähe
+
+
+
+
+ Manuell
+
+
+
+
+ GPS nicht verfügbar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Art der Arbeit *
+
+
+
+
+
+
+
+
+
+
+
+
+ Arbeitszeit
+
+
+
+
+
+
+
+
+
+
+ {{ entry.userName.charAt(0).toUpperCase() }}
+
+ {{ entry.userName }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatHoursValue(entry.hourCount).h }}
+ h
+ {{ formatHoursValue(entry.hourCount).m }}
+ m
+
+
+
+
+
+
+
+
+
+
+
+
+
+ km
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pos.articleName }}
+ {{ pos.articleNumber }}
+
+
+
+
+
+
+ {{ pos.amount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ (customer.displayName || customer.company || '?').charAt(0).toUpperCase() }}
+
+
+
+ {{ customer.displayName }}
+ {{ customer.company }}
+ {{ customer.street }}, {{ customer.zip }} {{ customer.city }}
+ KNr: {{ customer.customerNumber }}
+
+
+
+
+
+
+
+
+
+
+ {{ article.title }}
+ {{ article.articleNumber }}
+
+
+
+
+
+
+
+
+
+
+ {{ car.name }}
+ {{ car.plate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteList.js b/public/mobile/modules/lager/shippingnote/ShippingNoteList.js
new file mode 100644
index 000000000..284e7ada8
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteList.js
@@ -0,0 +1,227 @@
+/**
+ * ShippingNoteList Component
+ *
+ * Lists unsigned shipping notes for the current user.
+ * Features:
+ * - Pull to refresh
+ * - Tap to open signature modal
+ * - Shows customer, date, note preview
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+
+export default {
+ name: 'ShippingNoteList',
+ emits: ['sign', 'toast'],
+ props: {
+ user: Object
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted } = Vue;
+
+ // Data
+ const shippingNotes = ref([]);
+ const loading = ref(true);
+ const refreshing = ref(false);
+ const error = ref(null);
+
+ // Load shipping notes
+ const loadShippingNotes = async (isRefresh = false) => {
+ if (isRefresh) {
+ refreshing.value = true;
+ } else {
+ loading.value = true;
+ }
+ error.value = null;
+
+ try {
+ const data = await shippingNoteApi.get('getMyShippingNotes');
+
+ if (data.success) {
+ shippingNotes.value = data.shippingNotes || [];
+ } else {
+ error.value = data.error || 'Fehler beim Laden';
+ }
+ } catch (e) {
+ console.error('Failed to load shipping notes:', e);
+ error.value = 'Netzwerkfehler';
+ } finally {
+ loading.value = false;
+ refreshing.value = false;
+ }
+ };
+
+ // Pull to refresh
+ let touchStartY = 0;
+ let isPulling = false;
+
+ const handleTouchStart = (e) => {
+ const scrollTop = e.currentTarget.scrollTop;
+ if (scrollTop === 0) {
+ touchStartY = e.touches[0].clientY;
+ isPulling = true;
+ }
+ };
+
+ const handleTouchMove = (e) => {
+ if (!isPulling) return;
+ const deltaY = e.touches[0].clientY - touchStartY;
+ if (deltaY > 80 && !refreshing.value) {
+ loadShippingNotes(true);
+ isPulling = false;
+ }
+ };
+
+ const handleTouchEnd = () => {
+ isPulling = false;
+ };
+
+ // Open signature for a shipping note
+ const openSignature = (shippingNote) => {
+ emit('sign', shippingNote);
+ navigator.vibrate?.([50]);
+ };
+
+ // Format date
+ const formatDate = (dateStr) => {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('de-AT', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric'
+ });
+ };
+
+ // Initialize
+ onMounted(() => {
+ loadShippingNotes();
+ });
+
+ return {
+ shippingNotes,
+ loading,
+ refreshing,
+ error,
+ loadShippingNotes,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ openSignature,
+ formatDate
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+ Lade Lieferscheine...
+
+
+
+
+
+ {{ error }}
+
+
+
+
+
+
+ Alles unterschrieben!
+
+ Keine offenen Lieferscheine zum Unterschreiben.
+
+
+
+
+
+
+ {{ shippingNotes.length }} {{ shippingNotes.length === 1 ? 'Lieferschein' : 'Lieferscheine' }} zum Unterschreiben
+
+
+
+
+
+
+
+ Ziehen zum Aktualisieren
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js b/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
new file mode 100644
index 000000000..b5667a8ce
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
@@ -0,0 +1,164 @@
+import { createModuleApi } from '/mobile/shared/api.js';
+import ShippingNoteForm from '/mobile/modules/lager/shippingnote/ShippingNoteForm.js';
+import ShippingNoteList from '/mobile/modules/lager/shippingnote/ShippingNoteList.js';
+import SignaturePad from '/mobile/modules/lager/shippingnote/SignaturePad.js';
+
+const shippingNoteApi = createModuleApi('Lager/ShippingNote');
+
+export { shippingNoteApi };
+
+export default {
+ name: 'ShippingNoteModule',
+ emits: ['navigate', 'toast'],
+ props: {
+ user: Object,
+ submodule: String
+ },
+ components: {
+ ShippingNoteForm,
+ ShippingNoteList,
+ SignaturePad
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch, onMounted } = Vue;
+
+ // Current view: 'create' | 'list' | 'sign'
+ const currentTab = ref('create');
+
+ // Signature modal state
+ const showSignatureModal = ref(false);
+ const signatureShippingNoteId = ref(null);
+ const signatureShippingNote = ref(null);
+
+ // Last created shipping note (for immediate signing)
+ const lastCreatedId = ref(null);
+
+ // Open signature modal for a shipping note
+ const openSignature = (shippingNote) => {
+ signatureShippingNoteId.value = shippingNote.id;
+ signatureShippingNote.value = shippingNote;
+ showSignatureModal.value = true;
+ };
+
+ // Close signature modal
+ const closeSignature = () => {
+ showSignatureModal.value = false;
+ signatureShippingNoteId.value = null;
+ signatureShippingNote.value = null;
+ };
+
+ // Handle successful signature
+ const handleSignatureComplete = () => {
+ closeSignature();
+ emit('toast', 'Unterschrift gespeichert', 'success');
+ // Haptic feedback
+ navigator.vibrate?.([100, 50, 100]);
+ };
+
+ // Handle shipping note created
+ const handleCreated = (shippingNote) => {
+ lastCreatedId.value = shippingNote.id;
+ emit('toast', 'Lieferschein erstellt', 'success');
+ // Haptic feedback
+ navigator.vibrate?.([100]);
+ };
+
+ // Handle immediate sign after create
+ const handleCreateAndSign = (shippingNote) => {
+ handleCreated(shippingNote);
+ // Open signature modal immediately
+ openSignature(shippingNote);
+ };
+
+ // Show toast
+ const showToast = (message, type) => {
+ emit('toast', message, type);
+ };
+
+ // Switch tab
+ const switchTab = (tab) => {
+ currentTab.value = tab;
+ };
+
+ return {
+ currentTab,
+ showSignatureModal,
+ signatureShippingNoteId,
+ signatureShippingNote,
+ lastCreatedId,
+ openSignature,
+ closeSignature,
+ handleSignatureComplete,
+ handleCreated,
+ handleCreateAndSign,
+ showToast,
+ switchTab
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/SignaturePad.js b/public/mobile/modules/lager/shippingnote/SignaturePad.js
new file mode 100644
index 000000000..e1b5310df
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/SignaturePad.js
@@ -0,0 +1,300 @@
+/**
+ * SignaturePad Component
+ *
+ * Full-screen signature capture for shipping notes.
+ * Features:
+ * - Canvas-based signature drawing
+ * - Customer name input
+ * - Clear/retry functionality
+ * - Base64 PNG export
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+
+export default {
+ name: 'SignaturePad',
+ emits: ['close', 'signed', 'toast'],
+ props: {
+ shippingNoteId: [Number, String],
+ shippingNote: Object
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted, onUnmounted, nextTick } = Vue;
+
+ // Refs
+ const canvasRef = ref(null);
+ const signatureName = ref('');
+ const loading = ref(false);
+ const hasSignature = ref(false);
+
+ // Canvas context
+ let ctx = null;
+ let isDrawing = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ // Initialize canvas
+ onMounted(async () => {
+ await nextTick();
+ initCanvas();
+ window.addEventListener('resize', handleResize);
+ });
+
+ onUnmounted(() => {
+ window.removeEventListener('resize', handleResize);
+ });
+
+ const initCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ // Set canvas size to container
+ const container = canvas.parentElement;
+ canvas.width = container.clientWidth;
+ canvas.height = container.clientHeight;
+
+ ctx = canvas.getContext('2d');
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 3;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ // Fill with white background
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ };
+
+ const handleResize = () => {
+ // Save current signature
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ const imageData = canvas.toDataURL();
+
+ // Resize canvas
+ initCanvas();
+
+ // Restore signature
+ if (hasSignature.value) {
+ const img = new Image();
+ img.onload = () => {
+ ctx.drawImage(img, 0, 0);
+ };
+ img.src = imageData;
+ }
+ };
+
+ // Get position from touch/mouse event
+ const getPosition = (e) => {
+ const canvas = canvasRef.value;
+ const rect = canvas.getBoundingClientRect();
+
+ if (e.touches && e.touches.length > 0) {
+ return {
+ x: e.touches[0].clientX - rect.left,
+ y: e.touches[0].clientY - rect.top
+ };
+ }
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+ };
+
+ // Start drawing
+ const startDrawing = (e) => {
+ e.preventDefault();
+ isDrawing = true;
+ const pos = getPosition(e);
+ lastX = pos.x;
+ lastY = pos.y;
+ };
+
+ // Draw
+ const draw = (e) => {
+ if (!isDrawing) return;
+ e.preventDefault();
+
+ const pos = getPosition(e);
+
+ ctx.beginPath();
+ ctx.moveTo(lastX, lastY);
+ ctx.lineTo(pos.x, pos.y);
+ ctx.stroke();
+
+ lastX = pos.x;
+ lastY = pos.y;
+ hasSignature.value = true;
+ };
+
+ // Stop drawing
+ const stopDrawing = () => {
+ isDrawing = false;
+ };
+
+ // Clear canvas
+ const clearCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas || !ctx) return;
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ hasSignature.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Submit signature
+ const submitSignature = async () => {
+ if (!hasSignature.value) {
+ emit('toast', 'Bitte unterschreiben', 'error');
+ return;
+ }
+
+ if (!signatureName.value.trim()) {
+ emit('toast', 'Bitte Namen eingeben', 'error');
+ return;
+ }
+
+ loading.value = true;
+
+ try {
+ const canvas = canvasRef.value;
+ const signatureData = canvas.toDataURL('image/png');
+
+ const data = await shippingNoteApi.post(`sign?id=${props.shippingNoteId}`, {
+ signature: signatureData,
+ signatureName: signatureName.value.trim()
+ });
+
+ if (data.success) {
+ emit('signed', data);
+ } else {
+ emit('toast', data.error || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ console.error('Signature submit failed:', e);
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Close handler
+ const handleClose = () => {
+ emit('close');
+ };
+
+ return {
+ canvasRef,
+ signatureName,
+ loading,
+ hasSignature,
+ startDrawing,
+ draw,
+ stopDrawing,
+ clearCanvas,
+ submitSignature,
+ handleClose
+ };
+ },
+
+ template: `
+
+
+
+
+ Unterschrift
+
+
+
+
+
+ Lieferschein Nr.
+ {{ shippingNote.number || shippingNote.id }}
+
+ {{ shippingNote.customerName }}
+
+
+
+
+
+
+
+
+
+ Hier unterschreiben
+
+
+
+
+
+ Mit Finger unterschreiben
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/workorder/WorkorderModule.js b/public/mobile/modules/workorder/WorkorderModule.js
new file mode 100644
index 000000000..512625d1d
--- /dev/null
+++ b/public/mobile/modules/workorder/WorkorderModule.js
@@ -0,0 +1,1643 @@
+/**
+ * Workorder Module (Aufträge)
+ *
+ * Main module for workorder management in MobileApp.
+ * Provides list view and full-screen detail view with collapsible cards.
+ * Supports offline mode via WorkorderOfflineService.
+ */
+
+import workorderService from '/mobile/shared/workorderOfflineService.js';
+import photoQueue from '/mobile/shared/photoQueue.js';
+import { isOfflineModeEnabled } from '/mobile/shared/offlineSettings.js';
+
+export default {
+ name: 'WorkorderModule',
+ emits: ['navigate', 'toast', 'detail-open', 'detail-close'],
+ props: {
+ user: Object,
+ submodule: String
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch, onMounted, nextTick } = Vue;
+
+ // =====================
+ // STATE
+ // =====================
+ const isLoading = ref(true);
+ const workorders = ref([]);
+ const searchTerm = ref('');
+ const selectedFcp = ref('all');
+ const showFcpFilter = ref(false);
+
+ // Detail view state
+ const selectedWorkorder = ref(null);
+ const isDetailLoading = ref(false);
+ const documentation = ref({ docs: [], journals: [] });
+ const tenantConfig = ref(null);
+ const checklist = ref([]);
+ const technicalData = ref(null);
+
+ // Expanded cards state
+ const expandedCards = ref({
+ customer: true,
+ checklist: true,
+ documentation: false,
+ notes: false,
+ journal: false,
+ cableData: false,
+ technical: true
+ });
+
+ // Edit states
+ const isEditingNotes = ref(false);
+ const tempNotes = ref('');
+ const newJournalText = ref('');
+ const cableDataForm = ref({ cableLength: '', cableType: '' });
+
+ // Bottom sheets
+ const showDocUploadSheet = ref(false);
+ const showProblemSheet = ref(false);
+ const showCompleteSheet = ref(false);
+ const showPdfViewer = ref(false);
+ const pdfViewerUrl = ref('');
+ const pdfViewerTitle = ref('');
+ const showImageViewer = ref(false);
+ const imageViewerUrl = ref('');
+
+ // Upload state
+ const uploadDocType = ref('');
+ const isUploading = ref(false);
+ const fileInputRef = ref(null);
+ const pendingChecklistUpload = ref(null); // For auto-advance camera
+
+ // Problem form
+ const problemType = ref('');
+ const problemComment = ref('');
+
+ const swipeStartX = ref(0);
+ const swipeCardId = ref(null);
+ const swipeOffset = ref({});
+ const swipeTriggered = ref(false);
+
+ // =====================
+ // COMPUTED
+ // =====================
+ const fcpOptions = computed(() => {
+ if (!workorders.value.length) return [{ value: 'all', text: 'Alle FCPs' }];
+ const fcps = [...new Set(workorders.value.map(wo => wo.fcpName).filter(Boolean))].sort();
+ return [{ value: 'all', text: 'Alle FCPs' }, ...fcps.map(fcp => ({ value: fcp, text: fcp }))];
+ });
+
+ const filteredWorkorders = computed(() => {
+ let filtered = workorders.value;
+
+ // FCP filter
+ if (selectedFcp.value !== 'all') {
+ filtered = filtered.filter(wo => wo.fcpName === selectedFcp.value);
+ }
+
+ // Search filter
+ if (searchTerm.value.length > 2) {
+ const term = searchTerm.value.toLowerCase();
+ filtered = filtered.filter(wo =>
+ wo.id.toString().includes(term) ||
+ (wo.oaid && wo.oaid.toLowerCase().includes(term)) ||
+ (wo.fcpName && wo.fcpName.toLowerCase().includes(term)) ||
+ (wo.customerName && wo.customerName.toLowerCase().includes(term)) ||
+ (wo.customerAddress && wo.customerAddress.toLowerCase().includes(term))
+ );
+ }
+
+ // Sort by status priority
+ const getStatusRank = (status) => {
+ switch (status) {
+ case 'scheduled':
+ case 'civil_engineering_completed': return 0;
+ case 'assigned':
+ case 'new':
+ case 'problem_solved': return 1;
+ case 'in_progress': return 2;
+ case 'intervention_required':
+ case 'correction_requested': return 3;
+ case 'documented':
+ case 'completed': return 4;
+ default: return 99;
+ }
+ };
+
+ return filtered.sort((a, b) => {
+ const rankA = getStatusRank(a.status);
+ const rankB = getStatusRank(b.status);
+ if (rankA !== rankB) return rankA - rankB;
+ return (a.appointmentDate || Infinity) - (b.appointmentDate || Infinity);
+ });
+ });
+
+ const checklistProgress = computed(() => {
+ if (!checklist.value.length) return { completed: 0, total: 0, text: '0/0' };
+ const completed = checklist.value.filter(c => c.completed).length;
+ const total = checklist.value.length;
+ return { completed, total, text: `${completed}/${total}` };
+ });
+
+ const isChecklistComplete = computed(() => {
+ // Check required items are completed (if any are marked as required)
+ const requiredItems = checklist.value.filter(c => c.required);
+ if (requiredItems.length > 0) {
+ const allRequiredComplete = requiredItems.every(c => c.completed);
+ if (!allRequiredComplete) return false;
+ } else if (checklist.value.length > 0) {
+ const allCompleted = checklist.value.every(c => c.completed);
+ if (!allCompleted) return false;
+ }
+
+ if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) {
+ return false;
+ }
+ if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) {
+ return false;
+ }
+
+ return true;
+ });
+
+ const incompleteItems = computed(() => {
+ const items = [];
+ checklist.value.filter(c => !c.completed).forEach(c => {
+ items.push(c.text);
+ });
+ if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) {
+ items.push('Kabellänge');
+ }
+ if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) {
+ items.push('Kabeltyp');
+ }
+ return items;
+ });
+
+ const googleMapsLink = computed(() => {
+ if (!selectedWorkorder.value?.customer) return '#';
+ const c = selectedWorkorder.value.customer;
+ const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`);
+ return `https://maps.google.com/maps?q=${address}`;
+ });
+
+ // =====================
+ // METHODS
+ // =====================
+ const fetchWorkorders = async () => {
+ isLoading.value = true;
+ try {
+ const data = await workorderService.getWorkorders({ pagination: { page: 1, per_page: 500 } });
+ if (data.success) {
+ workorders.value = data.workorders;
+ if (data.fromCache) {
+ console.log('[WorkorderModule] Loaded from cache');
+ }
+ } else if (data.offline) {
+ emit('toast', 'Offline - Daten aus Cache', 'info');
+ }
+ } catch (error) {
+ console.error('Error fetching workorders:', error);
+ emit('toast', 'Fehler beim Laden der Aufträge', 'error');
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const openDetail = async (workorder) => {
+ selectedWorkorder.value = workorder;
+ isDetailLoading.value = true;
+ expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false, technical: true };
+ emit('detail-open', workorder.id);
+
+ try {
+ // Fetch all workorder details via offline service
+ const data = await workorderService.getWorkorderDetail(workorder.id);
+
+ if (data.success) {
+ selectedWorkorder.value = data.workorder;
+ cableDataForm.value = {
+ cableLength: data.workorder.cableLength || '',
+ cableType: data.workorder.cableType || ''
+ };
+ documentation.value = data.documentation || { docs: [], journals: [] };
+ tenantConfig.value = data.tenantConfig;
+ checklist.value = data.checklist || [];
+ technicalData.value = data.technicalData || null;
+ if (data.fromCache) {
+ console.log('[WorkorderModule] Detail loaded from cache');
+ }
+ } else if (data.offline) {
+ emit('toast', 'Offline - keine gecachten Details', 'error');
+ closeDetail();
+ } else {
+ emit('toast', data.message || 'Fehler beim Laden', 'error');
+ }
+ } catch (error) {
+ console.error('Error loading detail:', error);
+ emit('toast', 'Fehler beim Laden der Details', 'error');
+ } finally {
+ isDetailLoading.value = false;
+ }
+ };
+
+ const closeDetail = () => {
+ selectedWorkorder.value = null;
+ documentation.value = { docs: [], journals: [] };
+ tenantConfig.value = null;
+ checklist.value = [];
+ technicalData.value = null;
+ isEditingNotes.value = false;
+ emit('detail-close');
+ };
+
+ const toggleCard = (cardId) => {
+ expandedCards.value[cardId] = !expandedCards.value[cardId];
+ };
+
+ // Notes editing
+ const startEditNotes = () => {
+ tempNotes.value = selectedWorkorder.value.additionalInfo || '';
+ isEditingNotes.value = true;
+ };
+
+ const cancelEditNotes = () => {
+ isEditingNotes.value = false;
+ tempNotes.value = '';
+ };
+
+ const saveNotes = async () => {
+ try {
+ const data = await workorderService.updateNotes(selectedWorkorder.value.id, tempNotes.value);
+ if (data.success) {
+ selectedWorkorder.value.additionalInfo = tempNotes.value;
+ isEditingNotes.value = false;
+ if (data.queued) {
+ emit('toast', 'Notiz wird synchronisiert', 'info');
+ } else {
+ emit('toast', 'Notiz gespeichert', 'success');
+ // Refresh detail to get updated journals
+ const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id);
+ if (detailData.success && detailData.documentation) {
+ documentation.value.journals = detailData.documentation.journals || [];
+ }
+ }
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Speichern', 'error');
+ }
+ };
+
+ // Journal
+ const addJournalEntry = async () => {
+ if (!newJournalText.value.trim()) return;
+ try {
+ const data = await workorderService.addJournal(selectedWorkorder.value.id, newJournalText.value);
+ if (data.success) {
+ if (data.queued) {
+ // Add pending journal entry to local display
+ documentation.value.journals.unshift({
+ id: 'pending-' + data.localId,
+ text: newJournalText.value,
+ create: Math.floor(Date.now() / 1000),
+ _pending: true
+ });
+ emit('toast', 'Eintrag wird synchronisiert', 'info');
+ } else if (data.journals) {
+ documentation.value.journals = data.journals;
+ emit('toast', 'Eintrag hinzugefügt', 'success');
+ }
+ newJournalText.value = '';
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Hinzufügen', 'error');
+ }
+ };
+
+ // Cable data
+ const saveCableData = async () => {
+ try {
+ const data = await workorderService.updateCableData(
+ selectedWorkorder.value.id,
+ cableDataForm.value.cableLength,
+ cableDataForm.value.cableType
+ );
+ if (data.success) {
+ if (data.queued) {
+ emit('toast', 'Daten werden synchronisiert', 'info');
+ } else {
+ emit('toast', 'Daten gespeichert', 'success');
+ }
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Speichern', 'error');
+ }
+ };
+
+ // Documentation upload
+ const openDocUpload = () => {
+ if (tenantConfig.value?.documentationTypes?.length) {
+ uploadDocType.value = tenantConfig.value.documentationTypes[0].value;
+ }
+ showDocUploadSheet.value = true;
+ };
+
+ const triggerFileInput = () => {
+ if (fileInputRef.value) {
+ fileInputRef.value.click();
+ }
+ };
+
+ const handleFileSelect = async (event) => {
+ const files = event.target.files;
+ if (!files || !files.length) return;
+
+ isUploading.value = true;
+ const wasFromChecklist = pendingChecklistUpload.value !== null;
+
+ // Check if offline mode is enabled and we're offline
+ const offlineEnabled = isOfflineModeEnabled();
+ const isOffline = !navigator.onLine;
+
+ if (offlineEnabled && isOffline) {
+ // Queue files for later upload
+ try {
+ for (let i = 0; i < files.length; i++) {
+ const result = await photoQueue.queue(
+ selectedWorkorder.value.id,
+ files[i],
+ uploadDocType.value,
+ ''
+ );
+ if (result.success) {
+ // Add pending doc to local display
+ documentation.value.docs.push({
+ id: 'pending-' + result.localId,
+ documentType: uploadDocType.value,
+ fileName: files[i].name,
+ _pending: true
+ });
+ }
+ }
+ triggerHaptic('light');
+ emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info');
+ showDocUploadSheet.value = false;
+ } catch (error) {
+ emit('toast', 'Fehler beim Speichern', 'error');
+ } finally {
+ isUploading.value = false;
+ if (fileInputRef.value) fileInputRef.value.value = '';
+ pendingChecklistUpload.value = null;
+ }
+ return;
+ }
+
+ // Online upload
+ const formData = new FormData();
+ formData.append('workorderId', selectedWorkorder.value.id);
+ formData.append('documentType', uploadDocType.value);
+
+ for (let i = 0; i < files.length; i++) {
+ formData.append('files[]', files[i]);
+ }
+
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/uploadDocumentation', {
+ method: 'POST',
+ credentials: 'include',
+ body: formData
+ });
+ const data = await response.json();
+ if (data.success) {
+ triggerHaptic('light');
+ emit('toast', 'Dokument hochgeladen', 'success');
+ showDocUploadSheet.value = false;
+
+ // Refresh documentation and checklist
+ const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id);
+ if (detailData.success) {
+ documentation.value = detailData.documentation || { docs: [], journals: [] };
+ checklist.value = detailData.checklist || [];
+ }
+
+ // Auto-advance: If upload was from checklist, open camera for next item
+ if (wasFromChecklist) {
+ pendingChecklistUpload.value = null;
+ await nextTick();
+ const nextItem = getNextUncompletedItem();
+ if (nextItem) {
+ // Short delay so user sees the check mark
+ setTimeout(() => {
+ quickUploadForItem(nextItem);
+ }, 500);
+ }
+ }
+ }
+ } catch (error) {
+ // If upload fails and offline mode is enabled, queue for later
+ if (offlineEnabled) {
+ for (let i = 0; i < files.length; i++) {
+ await photoQueue.queue(selectedWorkorder.value.id, files[i], uploadDocType.value, '');
+ }
+ emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info');
+ } else {
+ emit('toast', 'Upload fehlgeschlagen', 'error');
+ }
+ } finally {
+ isUploading.value = false;
+ if (fileInputRef.value) fileInputRef.value.value = '';
+ }
+ };
+
+ // Problem reporting
+ const openProblemSheet = () => {
+ problemType.value = tenantConfig.value?.interventionTypes?.[0]?.value || '';
+ problemComment.value = '';
+ showProblemSheet.value = true;
+ };
+
+ const submitProblem = async () => {
+ if (!problemType.value && !problemComment.value) {
+ emit('toast', 'Bitte Grund angeben', 'error');
+ return;
+ }
+
+ const typeText = tenantConfig.value?.interventionTypes?.find(t => t.value === problemType.value)?.text || problemType.value;
+
+ try {
+ const data = await workorderService.requestIntervention(
+ selectedWorkorder.value.id,
+ typeText,
+ problemComment.value || typeText
+ );
+ if (data.success) {
+ if (data.queued) {
+ emit('toast', 'Problem wird bei Verbindung gemeldet', 'info');
+ } else {
+ emit('toast', 'Problem gemeldet', 'success');
+ }
+ showProblemSheet.value = false;
+ closeDetail();
+ fetchWorkorders();
+ } else {
+ emit('toast', data.message || 'Fehler', 'error');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Melden', 'error');
+ }
+ };
+
+ // Complete workorder
+ const openCompleteSheet = () => {
+ showCompleteSheet.value = true;
+ };
+
+ const submitComplete = async () => {
+ try {
+ const data = await workorderService.completeWorkorder(selectedWorkorder.value.id);
+ if (data.success) {
+ triggerHaptic('success');
+ emit('toast', 'Auftrag abgeschlossen', 'success');
+ showCompleteSheet.value = false;
+ closeDetail();
+ fetchWorkorders();
+ } else if (data.blocked) {
+ // Completion blocked when offline
+ emit('toast', data.message || 'Bitte zuerst synchronisieren', 'warning');
+ } else {
+ emit('toast', data.message || 'Fehler', 'error');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Abschließen', 'error');
+ }
+ };
+
+ const formatDate = (timestamp, format = 'date') => {
+ if (!timestamp) return '–';
+ const date = new Date(timestamp * 1000);
+ if (format === 'datetime') {
+ return date.toLocaleDateString('de-DE') + ' ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
+ }
+ return date.toLocaleDateString('de-DE');
+ };
+
+ const getStatusColor = (color) => {
+ const colors = {
+ primary: 'bg-blue-500',
+ info: 'bg-sky-500',
+ warning: 'bg-amber-500',
+ danger: 'bg-red-500',
+ success: 'bg-green-500',
+ secondary: 'bg-slate-500',
+ orange: 'bg-orange-500',
+ purple: 'bg-purple-500',
+ muted: 'bg-gray-400'
+ };
+ return colors[color] || 'bg-gray-500';
+ };
+
+ // Get left border color for status indicator on cards
+ const getStatusBorderColor = (status) => {
+ const borderColors = {
+ 'new': 'border-l-blue-500',
+ 'assigned': 'border-l-sky-500',
+ 'scheduled': 'border-l-amber-500',
+ 'in_progress': 'border-l-amber-500',
+ 'correction_requested': 'border-l-red-500',
+ 'intervention_required': 'border-l-red-500',
+ 'civil_engineering_required': 'border-l-orange-500',
+ 'civil_engineering_completed': 'border-l-green-500',
+ 'problem_solved': 'border-l-green-500',
+ 'documented': 'border-l-green-500',
+ 'completed': 'border-l-slate-500',
+ 'charged': 'border-l-purple-500',
+ 'cancelled': 'border-l-red-500',
+ 'archived': 'border-l-gray-400'
+ };
+ return borderColors[status] || 'border-l-gray-400';
+ };
+
+ // Quick camera upload for checklist item
+ const quickUploadForItem = (item) => {
+ uploadDocType.value = item.type;
+ pendingChecklistUpload.value = item.type;
+ nextTick(() => {
+ if (fileInputRef.value) {
+ fileInputRef.value.click();
+ }
+ });
+ };
+
+ // Get next uncompleted checklist item
+ const getNextUncompletedItem = () => {
+ return checklist.value.find(item => !item.completed);
+ };
+
+ // Haptic feedback
+ const triggerHaptic = (type = 'success') => {
+ if ('vibrate' in navigator) {
+ if (type === 'success') {
+ navigator.vibrate([50, 50, 100]);
+ } else {
+ navigator.vibrate(50);
+ }
+ }
+ };
+
+ const scrollIntoViewOnFocus = (e) => {
+ setTimeout(() => {
+ e.target.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }, 300);
+ };
+
+ const handleTouchStart = (e, wo) => {
+ swipeStartX.value = e.touches[0].clientX;
+ swipeCardId.value = wo.id;
+ swipeTriggered.value = false;
+ };
+
+ const handleTouchMove = (e, wo) => {
+ if (swipeCardId.value !== wo.id) return;
+ const currentX = e.touches[0].clientX;
+ const diff = swipeStartX.value - currentX;
+
+ if (diff > 0) {
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: Math.min(diff, 100) };
+ if (diff > 10) {
+ swipeTriggered.value = true;
+ }
+ } else {
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 };
+ }
+ };
+
+ const handleTouchEnd = (e, wo) => {
+ if (swipeCardId.value !== wo.id) return;
+ const offset = swipeOffset.value[wo.id] || 0;
+
+ if (offset > 60 && wo.customerAddress) {
+ swipeTriggered.value = true;
+ triggerHaptic('light');
+ const address = encodeURIComponent(wo.customerAddress);
+ window.open(`https://maps.google.com/maps?q=${address}`, '_blank');
+ }
+
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 };
+ swipeCardId.value = null;
+ };
+
+ const handleCardClick = (wo) => {
+ if (swipeTriggered.value) {
+ swipeTriggered.value = false;
+ return;
+ }
+ openDetail(wo);
+ };
+
+ const getSwipeStyle = (woId) => {
+ const offset = swipeOffset.value[woId] || 0;
+ return {
+ transform: `translateX(-${offset}px)`,
+ transition: swipeCardId.value === woId ? 'none' : 'transform 0.3s ease-out'
+ };
+ };
+
+ // Open navigation directly
+ const openNavigation = () => {
+ if (selectedWorkorder.value?.customer) {
+ const c = selectedWorkorder.value.customer;
+ const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`);
+ window.open(`https://maps.google.com/maps?q=${address}`, '_blank');
+ }
+ };
+
+ // Call customer directly
+ const callCustomer = () => {
+ if (selectedWorkorder.value?.customer?.phone) {
+ window.location.href = `tel:${selectedWorkorder.value.customer.phone}`;
+ }
+ };
+
+ // Smart complete - show confirmation sheet
+ const handleComplete = () => {
+ if (isChecklistComplete.value) {
+ showCompleteSheet.value = true;
+ }
+ // Button is disabled when not complete, so this won't be called
+ };
+
+ // Open PDF in viewer
+ const openPdfViewer = (url, title) => {
+ pdfViewerUrl.value = url;
+ pdfViewerTitle.value = title || 'PDF';
+ showPdfViewer.value = true;
+ };
+
+ // Open image in fullscreen viewer with zoom
+ const openImageViewer = (url) => {
+ imageViewerUrl.value = url;
+ showImageViewer.value = true;
+ };
+ const closeImageViewer = () => {
+ showImageViewer.value = false;
+ imageViewerUrl.value = '';
+ };
+
+ // Initialize
+ onMounted(() => {
+ fetchWorkorders();
+ });
+
+ return {
+ // State
+ isLoading,
+ workorders,
+ searchTerm,
+ selectedFcp,
+ showFcpFilter,
+ fcpOptions,
+ filteredWorkorders,
+ selectedWorkorder,
+ isDetailLoading,
+ documentation,
+ tenantConfig,
+ checklist,
+ technicalData,
+ expandedCards,
+ isEditingNotes,
+ tempNotes,
+ newJournalText,
+ cableDataForm,
+ showDocUploadSheet,
+ showProblemSheet,
+ showCompleteSheet,
+ showPdfViewer,
+ pdfViewerUrl,
+ pdfViewerTitle,
+ uploadDocType,
+ isUploading,
+ fileInputRef,
+ problemType,
+ problemComment,
+ checklistProgress,
+ isChecklistComplete,
+ incompleteItems,
+ googleMapsLink,
+
+ // Methods
+ fetchWorkorders,
+ openDetail,
+ closeDetail,
+ toggleCard,
+ startEditNotes,
+ cancelEditNotes,
+ saveNotes,
+ addJournalEntry,
+ saveCableData,
+ openDocUpload,
+ triggerFileInput,
+ handleFileSelect,
+ openProblemSheet,
+ submitProblem,
+ openCompleteSheet,
+ submitComplete,
+ formatDate,
+ getStatusColor,
+ getStatusBorderColor,
+ quickUploadForItem,
+ openNavigation,
+ callCustomer,
+ handleComplete,
+ openPdfViewer,
+ openImageViewer,
+ closeImageViewer,
+ showImageViewer,
+ imageViewerUrl,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ handleCardClick,
+ getSwipeStyle,
+ swipeOffset,
+ scrollIntoViewOnFocus
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Aufträge gefunden
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ wo.oaid }}
+ #{{ wo.id }}
+ {{ wo.fcpName }}
+
+ {{ wo.customerName || 'Unbekannt' }}
+ {{ wo.customerAddress }}
+
+
+
+ {{ wo.statusText }}
+
+
+ {{ wo.appointmentFormatted }}
+
+
+ Frist: {{ wo.deadlineFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedWorkorder.oaid }}
+ #{{ selectedWorkorder.id }}
+ {{ selectedWorkorder.fcpName }}
+
+
+ {{ selectedWorkorder.statusText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedWorkorder.customer.name }}
+
+ {{ selectedWorkorder.customer.street }}, {{ selectedWorkorder.customer.zip }} {{ selectedWorkorder.customer.city }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Equipment: {{ technicalData.patchposition.equipmentName }}
+ Port: {{ technicalData.patchposition.equipmentPort }}
+
+
+ Cluster: {{ technicalData.patchposition.cluster }}
+ Shelf: {{ technicalData.patchposition.shelf }}
+ Module: {{ technicalData.patchposition.module }}
+
+
+
+
+
+
+
+ Dropkabel
+ {{ technicalData.dropcable.entries.length }}
+
+
+
+
+ {{ dk.cable_id }}
+ {{ dk.status || '-' }}
+
+
+ {{ dk.type }}
+
+ Plan: {{ dk.laenge_plan || '-' }}
+ Ist: {{ dk.laenge_ist || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ wo.rimoName }}
+ {{ wo.rimoStatus }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Checkliste vorhanden
+
+
+ -
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Dokumente vorhanden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedWorkorder.additionalInfo || 'Keine Notiz vorhanden.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Keine Einträge
+
+
+
+ {{ entry.text }}
+ {{ entry.createFormatted }} - {{ entry.createByName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dokumentation hochladen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Problem melden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Auftrag abschließen
+
+
+
+
+
+ Alle Anforderungen erfüllt!
+ Der Auftrag kann abgeschlossen werden.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pdfViewerTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ![Lageplan]()
+
+
+
+ Pinch zum Zoomen • Doppeltippen für 2x
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/shared/api.js b/public/mobile/shared/api.js
new file mode 100644
index 000000000..b3481f9ff
--- /dev/null
+++ b/public/mobile/shared/api.js
@@ -0,0 +1,18 @@
+export function createModuleApi(modulePath) {
+ return {
+ get: (endpoint) => fetch(`/MobileApp/${modulePath}/${endpoint}`).then(r => r.json()),
+ post: (endpoint, data) => fetch(`/MobileApp/${modulePath}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ }).then(r => r.json())
+ };
+}
+
+export const debounce = (fn, delay) => {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), delay);
+ };
+};
diff --git a/public/mobile/shared/auth.js b/public/mobile/shared/auth.js
new file mode 100644
index 000000000..57ce12eef
--- /dev/null
+++ b/public/mobile/shared/auth.js
@@ -0,0 +1,199 @@
+/**
+ * MobileApp Shared Authentication Module
+ *
+ * Provides authentication utilities for all mobile PWAs:
+ * - checkAuth() - Check if user is authenticated
+ * - login() - Authenticate user
+ * - logout() - Clear session
+ * - api - Generic API helper
+ */
+
+// Base API path for all MobileApp endpoints
+const API_BASE = '/MobileApp';
+
+// Shared auth state (can be imported by components)
+export const authState = {
+ user: null,
+ isAuthenticated: false
+};
+
+/**
+ * Check if user is currently authenticated
+ * @returns {Promise<{authenticated: boolean, user?: object}>}
+ */
+export async function checkAuth() {
+ try {
+ const res = await fetch(`${API_BASE}/auth/check`, {
+ credentials: 'same-origin'
+ });
+ const data = await res.json();
+
+ authState.isAuthenticated = data.authenticated;
+ authState.user = data.user || null;
+
+ return data;
+ } catch (e) {
+ console.error('Auth check failed:', e);
+ authState.isAuthenticated = false;
+ authState.user = null;
+ return { authenticated: false };
+ }
+}
+
+/**
+ * Authenticate user with credentials
+ * @param {object} credentials - { username, password, rememberMe }
+ * @returns {Promise<{success: boolean, user?: object, message?: string}>}
+ */
+export async function login({ username, password, rememberMe = true }) {
+ try {
+ const res = await fetch(`${API_BASE}/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ username, password, rememberMe })
+ });
+
+ const data = await res.json();
+
+ if (data.success) {
+ authState.isAuthenticated = true;
+ authState.user = data.user;
+ }
+
+ return data;
+ } catch (e) {
+ console.error('Login failed:', e);
+ return { success: false, message: 'Netzwerkfehler' };
+ }
+}
+
+/**
+ * Verify 2FA code
+ * @param {string} code - 5-digit verification code
+ * @returns {Promise<{success: boolean, user?: object, message?: string}>}
+ */
+export async function verify2FA(code) {
+ try {
+ const res = await fetch(`${API_BASE}/auth/verify2fa`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ code })
+ });
+
+ const data = await res.json();
+
+ if (data.success) {
+ authState.isAuthenticated = true;
+ authState.user = data.user;
+ }
+
+ return data;
+ } catch (e) {
+ console.error('2FA verification failed:', e);
+ return { success: false, message: 'Netzwerkfehler' };
+ }
+}
+
+/**
+ * Resend 2FA code
+ * @returns {Promise<{success: boolean, message?: string}>}
+ */
+export async function resend2FA() {
+ try {
+ const res = await fetch(`${API_BASE}/auth/resend2fa`, {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+
+ return await res.json();
+ } catch (e) {
+ console.error('Resend 2FA failed:', e);
+ return { success: false, message: 'Netzwerkfehler' };
+ }
+}
+
+/**
+ * Logout current user
+ * @returns {Promise<{success: boolean}>}
+ */
+export async function logout() {
+ try {
+ await fetch(`${API_BASE}/auth/logout`, {
+ method: 'POST',
+ credentials: 'same-origin'
+ });
+ } catch (e) {
+ console.error('Logout request failed:', e);
+ }
+
+ authState.isAuthenticated = false;
+ authState.user = null;
+
+ return { success: true };
+}
+
+/**
+ * Generic API helper for app-specific endpoints
+ * Usage: api.get('WarehouseStocktake/getActiveStocktakes')
+ * api.post('WarehouseStocktake/submitScan', { ... })
+ */
+export const api = {
+ /**
+ * GET request
+ * @param {string} endpoint - Endpoint path (e.g., 'WarehouseStocktake/getArticle?code=123')
+ * @returns {Promise | | |