Merge branch 'master' into fronkdev
This commit is contained in:
@@ -431,6 +431,8 @@ $pagination_entity_name = "Zustimmungserklärungen";
|
||||
$approve_override = $item->approve_override;
|
||||
if (isset($approve_override) && $approve_override) {
|
||||
$status_class = 'green'; // Blue if approve override
|
||||
} if (isset($item->owner_result_counts['new']) && $item->owner_result_counts['new'] > 0) {
|
||||
$status_class = 'blue';
|
||||
} elseif (isset($item->owner_result_counts['denied']) && $item->owner_result_counts['denied'] > 0) {
|
||||
$status_class = 'red';
|
||||
// Red if at least one denied
|
||||
|
||||
@@ -295,7 +295,7 @@ $pagination_entity_name = "Adressen";
|
||||
<h5>Zusammenfassung Status (von <?=count($item->owners)?>)</h5>
|
||||
<table class="table table-sm">
|
||||
<?php foreach($item->owner_status_counts as $type => $count): ?>
|
||||
<tr class="ConstructionConsentOwnerResult-<?=$type?>">
|
||||
<tr class="ConstructionConsentOwnerResult-s<?=$type?>">
|
||||
<th><?=__($type, "consent")?></th>
|
||||
<td><?=$count?></td>
|
||||
</tr>
|
||||
@@ -308,6 +308,8 @@ $pagination_entity_name = "Adressen";
|
||||
$status_class = 'blue'; // Default to blue (all open)
|
||||
if (isset($item->owner_result_counts['denied']) && $item->owner_result_counts['denied'] > 0) {
|
||||
$status_class = 'red'; // Red if at least one denied
|
||||
} elseif (isset($item->owner_result_counts['open']) && $item->owner_result_counts['open'] > 0) {
|
||||
$status_class = 'blue'; // Blue if at least one open
|
||||
} elseif (
|
||||
(isset($item->owner_result_counts['unresolvable']) && $item->owner_result_counts['unresolvable'] > 0) ||
|
||||
(isset($item->owner_result_counts['moved']) && $item->owner_result_counts['moved'] > 0) ||
|
||||
@@ -1348,6 +1350,10 @@ $pagination_entity_name = "Adressen";
|
||||
background-color: #337ab7; /* Blue */
|
||||
}
|
||||
|
||||
.ConstructionConsentOwnerResult-new {
|
||||
background-color: #9bcdff;
|
||||
}
|
||||
|
||||
.ConstructionConsentOwnerResult-denied {
|
||||
/*red background color here for this tr*/
|
||||
background-color: #f9a39f
|
||||
|
||||
45
Layout/default/Cpeprovisioning/PDF_MAIN.php
Normal file
45
Layout/default/Cpeprovisioning/PDF_MAIN.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
$maxLength = max(mb_strlen($firstline ?? ''), mb_strlen($secondline ?? ''));
|
||||
|
||||
$fontSize = '12px';
|
||||
if ($maxLength <= 15) $fontSize = '24px';
|
||||
elseif ($maxLength <= 24) $fontSize = '18px';
|
||||
elseif ($maxLength <= 50) $fontSize = '16px';
|
||||
|
||||
$this->setReturnValue(['filename' => "xyz." . time() . "pdf"]);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>CPE-Etikett</title>
|
||||
<meta charset="utf-8"/>
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Open Sans", sans-serif, Verdana;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
font-size: <?= $fontSize ?>;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content-block">
|
||||
<div><?= $firstline ?></div>
|
||||
<div><?= $secondline ?></div>
|
||||
<div><?= $thirdline ?></div>
|
||||
<div><?= $fourthline ?></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -519,7 +519,7 @@ $pagination_entity_name = "Vorbestellungen";
|
||||
}
|
||||
}
|
||||
|
||||
$requiredFlagIds = [1, 3, 4, 5];
|
||||
$requiredFlagIds = [3, 4, 5];
|
||||
$allFlagsChecked = true;
|
||||
|
||||
foreach ($requiredFlagIds as $flagId) {
|
||||
|
||||
@@ -23,6 +23,7 @@ $additionalCSS = [
|
||||
'plugins/vue/tt-components/css/tt-table.css',
|
||||
'plugins/vue/tt-components/css/tt-tooltip.css',
|
||||
'plugins/vue/tt-components/css/tt-loader.css',
|
||||
'plugins/vue/tt-components/css/tt-file-gallery.css',
|
||||
'plugins/vue/tt-components/css/tt-position-manager.css',
|
||||
];
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
<?php endif; ?>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(".selectpicker").selectpicker({
|
||||
iconBase: "fas",
|
||||
tickIcon: "check",
|
||||
sanitize: false
|
||||
});
|
||||
if ($(".selectpicker").length) {
|
||||
$(".selectpicker").selectpicker({
|
||||
iconBase: "fas",
|
||||
tickIcon: "check",
|
||||
sanitize: false
|
||||
});
|
||||
}
|
||||
$('.navbar-toggle').on('click', function (event) {
|
||||
console.log('cracy');
|
||||
$(this).toggleClass('open');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap-autocomplete.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>datatables/datatables.min.js?<?=$git_merge_ts?>"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>assets/libs/switchery/switchery.min.js"></script>
|
||||
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
<li class="has-submenu">
|
||||
<a href="#">
|
||||
<?php if ($me->can("WarehouseEShop") && !($me->can("WarehouseAdmin") || $me->can("WarehouseUser"))): ?>
|
||||
<i class="fas fa-fw fa-shopping-cart"></i>E-Shop<div class="arrow-down"></div>
|
||||
<i class="fas fa-fw fa-shopping-cart"></i><?= $me->address_id == 9633 ? "SBIDI Shop" : "E-Shop" ?><div class="arrow-down"></div>
|
||||
<?php elseif ($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?>
|
||||
<i class="fas fa-fw fa-warehouse"></i>Lager<div class="arrow-down"></div>
|
||||
<?php endif; ?>
|
||||
@@ -179,9 +179,11 @@
|
||||
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>
|
||||
|
||||
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseEShop")): ?><li class="has-sub-submenu font-weight-bold"><a>E-Stmk Shop</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseEShop")): ?><li><a href="<?=self::getUrl("WarehouseEShop")?>"><i class="far fa-fw fa-shopping-cart text-info"></i> E-Shop</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseEShopOrder")?>"><i class="far fa-fw fa-shopping-basket text-info"></i> E-Shop Bestellungen</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseEShop")): ?><li class="has-sub-submenu font-weight-bold"><a> <?= $me->address_id == 9633 ? "SBIDI Shop" : "E-Shop" ?></a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseEShop") && !$me->isAdmin()): ?><li><a href="<?=self::getUrl("WarehouseEShop")?>"><i class="far fa-fw fa-shopping-cart text-info"></i> <?=$me->address_id == 9633 ? "SBIDI Shop" : "E-Shop" ?></a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseEShop") && $me->isAdmin()): ?><li><a href="<?=self::getUrl("WarehouseEShop")?>?shop=e"><i class="far fa-fw fa-box text-info"></i> E-Shop</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseEShop") && $me->isAdmin()): ?><li><a href="<?=self::getUrl("WarehouseEShop")?>?shop=sbidi"><i class="far fa-fw fa-box text-info"></i> SBIDI-Shop</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseEShopOrder")?>"><i class="far fa-fw fa-shopping-basket text-info"></i> E/SBIDI-Shop Bestellungen</a></li><?php endif; ?>
|
||||
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseArticlePacket")?>"><i class="far fa-fw fa-box text-info"></i> Artikel-Pakete</a></li><?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -39,11 +39,10 @@
|
||||
</script>
|
||||
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/jquery.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>assets/js/bootstrap-select.min.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js"></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/popper.min.js" defer></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>js/bootstrap.min.js" defer></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/notification/notify.js" defer></script>
|
||||
<script type="text/javascript" src="<?=self::getResourcePath()?>plugins/bookstack/bookstackIntegration.js" defer></script>
|
||||
|
||||
<?php if(isset($additionalJS) && is_array($additionalJS) && count($additionalJS)): ?>
|
||||
<?php foreach($additionalJS as $js): ?>
|
||||
|
||||
@@ -247,6 +247,9 @@ class ConstructionConsent extends mfBaseModel {
|
||||
}
|
||||
|
||||
foreach($owners as $owner) {
|
||||
if($owner->result == "open" && $owner->status == "new") {
|
||||
$owner->result = "new";
|
||||
}
|
||||
if(!array_key_exists($owner->result, $counts)) {
|
||||
$counts[$owner->result] = 0;
|
||||
}
|
||||
@@ -425,6 +428,10 @@ class ConstructionConsent extends mfBaseModel {
|
||||
COUNT(cwo.id) AS total_owners,
|
||||
CASE
|
||||
WHEN ConstructionConsent.approve_override = 1 THEN 'green'
|
||||
WHEN COALESCE(SUM(CASE
|
||||
WHEN approve_override = 1 THEN 0
|
||||
ELSE (cwo.result = 'open' AND cwo.status = 'new')
|
||||
END), 0) > 0 THEN 'blue'
|
||||
WHEN COALESCE(SUM(CASE
|
||||
WHEN approve_override = 1 THEN 0
|
||||
ELSE (cwo.result = 'denied')
|
||||
@@ -435,7 +442,7 @@ class ConstructionConsent extends mfBaseModel {
|
||||
END), 0) > 0
|
||||
OR COALESCE(SUM(CASE
|
||||
WHEN approve_override = 1 THEN 0
|
||||
ELSE (cwo.result = 'open')
|
||||
ELSE (cwo.result = 'open' AND cwo.status != 'new')
|
||||
END), 0) > 0
|
||||
OR COALESCE(SUM(CASE
|
||||
WHEN approve_override = 1 THEN 0
|
||||
|
||||
@@ -1112,6 +1112,22 @@ class ConstructionConsentController extends mfBaseController {
|
||||
$projectId = $this->request->project_id;
|
||||
$importData = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// save full post request and project id to file as log with metadata like user id etc as json file
|
||||
// MFUPLOAD_FILE_SAVE_PATH . /ConstructionConsentImports
|
||||
|
||||
$logData = [
|
||||
"user_id" => $this->me->id,
|
||||
"project_id" => $projectId,
|
||||
"import_data" => $importData,
|
||||
"timestamp" => date("Y-m-d H:i:s")
|
||||
];
|
||||
$logFileName = MFUPLOAD_FILE_SAVE_PATH . "/ConstructionConsentImports/import_" . date("Ymd_His") . "_user_{$this->me->id}_project_{$projectId}.json";
|
||||
if (!file_exists(dirname($logFileName))) {
|
||||
mkdir(dirname($logFileName), 0777, true);
|
||||
}
|
||||
file_put_contents($logFileName, json_encode($logData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
|
||||
if (empty($importData) || !is_array($importData)) { $this->layout()->setFlash("Keine Daten gefunden", "error"); return $this->redirect("ConstructionConsent"); }
|
||||
if (!is_numeric($projectId) || $projectId < 1) { $this->layout()->setFlash("Projekt nicht gefunden", "error"); return $this->redirect("ConstructionConsent"); }
|
||||
if (!($consentProject = new ConstructionConsentProject($projectId))->id) { $this->layout()->setFlash("Projekt nicht gefunden", "error"); return $this->redirect("ConstructionConsent"); }
|
||||
@@ -1178,7 +1194,10 @@ class ConstructionConsentController extends mfBaseController {
|
||||
|
||||
$journal = ConstructionConsentJournal::create([
|
||||
"constructionconsent_id" => $consentRecord->id,
|
||||
"text" => "Import: Eigentümer $firstname $lastname wurde hinzugefügt"
|
||||
"text" =>
|
||||
$ownerRecord->company ?
|
||||
"Import: Eigentümer $ownerRecord->company wurde hinzugefügt" :
|
||||
"Import: Eigentümer $firstname $lastname wurde hinzugefügt"
|
||||
]);
|
||||
$journal->save();
|
||||
|
||||
|
||||
@@ -156,6 +156,15 @@ class ConstructionConsentOwnerController extends mfBaseController
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
$journal = ConstructionConsentJournal::create([
|
||||
"constructionconsent_id" => $consent->id,
|
||||
"text" =>
|
||||
$owner->company ?
|
||||
"Eigentümer $owner->company wurde gelöscht" :
|
||||
"Eigentümer $owner->firstname $owner->lastname wurde gelöscht"
|
||||
]);
|
||||
$journal->save();
|
||||
|
||||
$owner->delete();
|
||||
|
||||
$this->layout()->setFlash("Besitzer gelöscht!", "success");
|
||||
|
||||
@@ -5,12 +5,11 @@ class CpeprovisioningController extends mfBaseController
|
||||
protected function init()
|
||||
{
|
||||
$this->needlogin = true;
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
$this->me = $me;
|
||||
$this->layout()->set("me", $me);
|
||||
$this->me = new User();
|
||||
$this->me->loadMe();
|
||||
$this->layout()->set("me", $this->me);
|
||||
|
||||
if (!$me->is(["Admin"])) {
|
||||
if (!$this->me->is(["Admin"])) {
|
||||
$this->redirect("Dashboard");
|
||||
}
|
||||
}
|
||||
@@ -283,11 +282,306 @@ class CpeprovisioningController extends mfBaseController
|
||||
$query["filter"] = $this->request->filter;
|
||||
}
|
||||
|
||||
$qs = http_build_query($query);
|
||||
|
||||
$this->layout()->setFlash("Eintrag erfolgreich gespeichert.", "success");
|
||||
$this->redirect("Cpeprovisioning", "Index", $qs);
|
||||
|
||||
$this->redirect("Cpeprovisioning", "Index", http_build_query($query));
|
||||
}
|
||||
|
||||
}
|
||||
protected function apiSaveAction() {
|
||||
try {
|
||||
$p = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
$id = $p['id'] ?? null;
|
||||
$mode = $id ? "edit" : "add";
|
||||
|
||||
if ($mode === 'edit') {
|
||||
$cpe = new Cpeprovisioning($id);
|
||||
if (!$cpe->id) throw new Exception("Eintrag nicht gefunden");
|
||||
}
|
||||
|
||||
if (empty($p['termination_id']) && empty($p['order_id'])) throw new Exception("Anschluss oder Bestellung nicht gefunden");
|
||||
if (!OrderProductModel::getFirst(["order_id" => $p['order_id'], "termination_id" => $p['termination_id']])) throw new Exception("Anschluss gehört nicht zur Bestellung");
|
||||
|
||||
if (!empty($p['ont_sn'])) {
|
||||
$termination = new Termination($p['termination_id']);
|
||||
$orig_sn = $termination->getWorkflowvalue("ont_sn", "string");
|
||||
|
||||
if ($orig_sn === null) {
|
||||
if ($sn_item = WorkflowitemModel::getFirst(["name" => "ont_sn", "object_type" => "termination"])) {
|
||||
$sn_item->setObjectId($p['termination_id']);
|
||||
$termination->workflowitems["ont_sn"] = $sn_item;
|
||||
} else {
|
||||
$this->log->error("ont_sn workflow item not found");
|
||||
}
|
||||
}
|
||||
|
||||
if ($p['ont_sn'] !== $orig_sn && isset($termination->workflowitems["ont_sn"])) {
|
||||
$termination->workflowitems["ont_sn"]->value->setValue($p['ont_sn']);
|
||||
$termination->workflowitems["ont_sn"]->value->save();
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
"termination_id" => $p['termination_id'] ?: null,
|
||||
"order_id" => $p['order_id'],
|
||||
"orderproduct_id" => $p['orderproduct_id'],
|
||||
"routerconfig_finished" => (int)($p['routerconfig_finished'] ?? 0),
|
||||
"shipping" => (int)($p['shipping'] ?? 0),
|
||||
"routertype" => $p['routertype'] ?? null,
|
||||
"wifi_ssid" => $p['wifi_ssid'] ?? null,
|
||||
"wifi_pass" => $p['wifi_pass'] ?? null,
|
||||
"mac" => $p['mac'] ?? null,
|
||||
"note" => $p['note'] ?? null,
|
||||
"edit_by" => $this->me->id,
|
||||
];
|
||||
|
||||
foreach (['ship_weight', 'ship_length', 'ship_width', 'ship_height'] as $key) $data[$key] = empty($p[$key]) ? null : $p[$key];
|
||||
foreach (['public', 'nat', 'ipv6'] as $type) $data["vlan_{$type}"] = !empty($p['vlans'][$type]['checked']) ? ($p['vlans'][$type]['tag'] ?? null) : null;
|
||||
|
||||
if ($mode === 'add') {
|
||||
$data["create_by"] = $this->me->id;
|
||||
$cpe = CpeprovisioningModel::create($data);
|
||||
} else $cpe->update($data);
|
||||
|
||||
if (!$cpe->save()) throw new Exception("Fehler beim Speichern");
|
||||
|
||||
if ($cpe->routerconfig_finished) {
|
||||
$order_product = new OrderProduct($p['orderproduct_id']);
|
||||
|
||||
$shipping_text = $cpe->shipping
|
||||
? "zum Versand vorbereitet"
|
||||
: "vorbereitet für Techniker zur Vorortinstallation";
|
||||
|
||||
$text = "CPE zu Produkt \"{$order_product->product->name}\" {$shipping_text}.\n\n"
|
||||
. "Router: {$cpe->routertype}\n"
|
||||
. "Zugangstyp: {$order_product->product->attributes['bras_type']->value}\n";
|
||||
|
||||
if ($cpe->vlan_public) $text .= "Vlan Public: {$cpe->vlan_public}\n";
|
||||
if ($cpe->vlan_nat) $text .= "Vlan NAT: {$cpe->vlan_nat}\n";
|
||||
if ($cpe->vlan_ipv6) $text .= "Vlan IPv6: {$cpe->vlan_ipv6}\n";
|
||||
|
||||
$journal = new OrderJournal();
|
||||
$journal->order_id = $p['order_id'];
|
||||
$journal->text = $text;
|
||||
$journal->create_by = $this->me->id;
|
||||
$journal->edit_by = $this->me->id;
|
||||
|
||||
if (!($journal_id = $journal->save())) {
|
||||
$this->layout()->setFlash("Konnte nicht ins Bestelljournal schreiben!", "warning");
|
||||
} else {
|
||||
$cpe->order_journal_id = $journal_id;
|
||||
$cpe->save();
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Eintrag erfolgreich gespeichert.']);
|
||||
|
||||
} catch (Exception $e) {
|
||||
http_response_code(400);
|
||||
self::returnJson(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
protected function apiGetAction()
|
||||
{
|
||||
$p = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
|
||||
// --- Pagination and Sorting setup ---
|
||||
$page = (int)($p['pagination']['page'] ?? 1);
|
||||
$perPage = (int)($p['pagination']['per_page'] ?? 25);
|
||||
$orderBy = $p['order']['key'] ?? null;
|
||||
$orderDir = $p['order']['order'] ?? 'asc';
|
||||
|
||||
// Calculate start and end indexes for manual pagination, just like in the old indexAction
|
||||
$start = ($page - 1) * $perPage;
|
||||
$end = $start + $perPage;
|
||||
|
||||
// --- Data Fetching (same as before) ---
|
||||
$searchFilter = $this->getPreparedFilter($p['filters'] ?? []);
|
||||
// if routerconfig_finished === 0 then we can add key add-where to searchFilter and only show orders withing the last 180 days either by create or edit (caution this is unix timestamp)
|
||||
if (isset($searchFilter['routerconfig_finished']) && !$searchFilter['routerconfig_finished']) {
|
||||
$searchFilter['add-where'] = "`Order`.create > " . (time() - 365 * 86400) . " OR `Order`.edit > " . (time() - 365 * 86400);
|
||||
}
|
||||
|
||||
$orders = OrderModel::search($searchFilter); // cpeprovisioning_enabled
|
||||
// Use the same uncached precache from indexAction to get all potential products
|
||||
// $prefetched = OrderProductModel::precache("`Terminationstatus`.`code` < " . TT_TERMSTATUS_CONNECTED . " AND `ProducttechAttribute`.`name` = 'bras_type'");
|
||||
$sqlWhere = "`ProducttechAttribute`.`name` = 'bras_type'";
|
||||
// if filter routerconfig_finished === 0 then we can filter the precache by routerconfig_finished = 0 or is null
|
||||
if (isset($searchFilter['routerconfig_finished']) && !$searchFilter['routerconfig_finished']) {
|
||||
// $sqlWhere .= " AND (`Cpeprovisioning`.`routerconfig_finished` = 0 OR `Cpeprovisioning`.`routerconfig_finished` IS NULL)";
|
||||
}
|
||||
|
||||
$prefetched = OrderProductModel::precache($sqlWhere);
|
||||
|
||||
$paginatedRows = [];
|
||||
$totalRows = 0;
|
||||
$orderInfoCache = []; // Cache for order-level info to avoid repeated calculations
|
||||
|
||||
foreach ($orders as $order) {
|
||||
// --- Apply the same initial filters as the old and new methods ---
|
||||
if (($searchFilter["hide_delayed_finish"] ?? false) && $order->finish_after && $order->finish_after > strtotime('+31 days')) continue;
|
||||
if (isset($prefetched['terminations'][$order->id][0]) && !$order->cpeprovisioning_enabled && $prefetched['terminations'][$order->id][0]['statuscode'] < TT_TERMSTATUS_CONNECTED) continue;
|
||||
if (empty($prefetched[$order->id])) continue;
|
||||
|
||||
// Loop through the prefetched raw data, similar to indexAction
|
||||
foreach ($prefetched[$order->id] as $opData) {
|
||||
// --- Apply the same product-level filters ---
|
||||
if (!is_array($opData) || ($opData['routerconfig_finished'] xor ($searchFilter['routerconfig_finished'] ?? false)) || empty($opData['attributes']['bras_type'])) continue;
|
||||
|
||||
// This item is a valid candidate for the list.
|
||||
$totalRows++;
|
||||
|
||||
// *** THE CORE PERFORMANCE IMPROVEMENT ***
|
||||
// If the current item is not on the page we want, skip the expensive processing below.
|
||||
if ($totalRows <= $start || $totalRows > $end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Now, do the expensive processing ONLY for the items on the current page ---
|
||||
|
||||
// Calculate and cache order-level info only when it's first needed for a visible item
|
||||
if (!isset($orderInfoCache[$order->id])) {
|
||||
$orderInfo = ['vot' => false, 'hw' => [], 'voip' => false];
|
||||
foreach ($order->products as $prod) {
|
||||
$attrs = $prod->product->attributes ?? [];
|
||||
if (empty($attrs) || !is_array($attrs)) continue;
|
||||
|
||||
if ($attrs['hw_only']->value ?? false) $orderInfo['hw'][] = (int)$prod->amount . "x " . $prod->product->name;
|
||||
if ($attrs['addon']->value ?? false) $orderInfo['hw'][] = $prod->product->name;
|
||||
if ($attrs['voip_chan']->value ?? false) $orderInfo['voip'] = true;
|
||||
if ($attrs['vot']->value ?? false) $orderInfo['vot'] = true;
|
||||
}
|
||||
$orderInfoCache[$order->id] = $orderInfo;
|
||||
}
|
||||
$orderInfo = $orderInfoCache[$order->id];
|
||||
|
||||
// Hydrate the full model object, but only for this one item
|
||||
$product = OrderProductModel::getOne($opData['id']);
|
||||
$term = $product->termination;
|
||||
$attrs = $product->product->attributes;
|
||||
$cpe = $product->cpeprovisioning;
|
||||
|
||||
$vlanPublicDefault = $term ? $term->getPop()->vlan_public : ($attrs['vlan_default_public']->value ?? null);
|
||||
$vlanNatDefault = $term ? $term->getPop()->vlan_nat : ($attrs['vlan_default_nat']->value ?? null);
|
||||
$vlanIpv6Default = $term ? $term->getPop()->vlan_ipv6 : ($attrs['vlan_default_ipv6']->value ?? null);
|
||||
|
||||
/** @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection */
|
||||
$paginatedRows[] = [
|
||||
'id' => $product->id, 'order_id' => $product->order_id, 'termination_id' => $product->termination_id, 'orderproduct_id' => $product->id,
|
||||
'network' => $term->building->network->name ?? "{$order->owner->zip} {$order->owner->city}",
|
||||
'spin' => $order->owner->spin, 'customer' => $order->owner->getCompanyOrName(),
|
||||
'product_name' => $product->product->name, 'product_code' => $term->code ?? '',
|
||||
'access_type' => $attrs['bras_type']->value,
|
||||
'access_type_down' => $attrs["bw_down"]->value,
|
||||
'access_type_up' => $attrs["bw_up"]->value,
|
||||
'ont_deployed' => $term ? $term->getWorkflowValue("ont_deployed", "int") : 0,
|
||||
'ont_sn' => $term ? $term->getWorkflowValue("ont_sn", "string") : null,
|
||||
'vot' => $orderInfo['vot'] || $order->install_date,
|
||||
'hw' => !empty($orderInfo['hw']) ? implode("<br />", $orderInfo['hw']) : null,
|
||||
'voip' => $orderInfo['voip'],
|
||||
'note' => $order->note,
|
||||
'show_snopp_button' => ($attrs['hostnetwork_order']->value ?? 0) == 1 && !str_contains($product->product->name, 'XDSL'),
|
||||
'snopp_url' => 'https://snopp.breitband-steiermark.at/Termination/index?filter[status][]=connected&filter[address]=' . urlencode($order->owner->street),
|
||||
'vlans' => [
|
||||
'public' => ['tag' => ($cpe ? $cpe->vlan_public : null) ?? $vlanPublicDefault, 'checked' => ($cpe ? $cpe->vlan_public : null)],
|
||||
'nat' => ['tag' => ($cpe ? $cpe->vlan_nat : null) ?? $vlanNatDefault, 'checked' => ($cpe ? $cpe->vlan_nat : null)],
|
||||
'ipv6' => ['tag' => ($cpe ? $cpe->vlan_ipv6 : null) ?? $vlanIpv6Default, 'checked' => ($cpe ? $cpe->vlan_ipv6 : null)],
|
||||
],
|
||||
'cpe_id' => $cpe->id ?? null,
|
||||
'cpe_data' => $this->fixCpeData($cpe->data ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the final (small) array of results for the current page
|
||||
if ($orderBy) {
|
||||
usort($paginatedRows, fn($a, $b) => ($orderDir === 'asc' ? 1 : -1) * strnatcasecmp($a[$orderBy] ?? '', $b[$orderBy] ?? ''));
|
||||
}
|
||||
|
||||
// No need for array_slice, we already built the paginated list manually
|
||||
self::returnJson([
|
||||
'rows' => $paginatedRows,
|
||||
'pagination' => [
|
||||
'page' => $page, 'per_page' => $perPage, 'total_rows' => $totalRows,
|
||||
'filtered_available' => $totalRows, 'total_pages' => ceil($totalRows / $perPage)
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
protected function newIndexAction()
|
||||
{
|
||||
$this->layout()->set('additionalJS', ['js/pages/Cpeprovisioning/Cpeprovisioning.js']);
|
||||
$this->layout()->set('additionalHead', ['<link rel="stylesheet" href="/js/pages/Cpeprovisioning/Cpeprovisioning.css">']);
|
||||
|
||||
Helper::renderVue(
|
||||
$this,
|
||||
"Cpeprovisioning", // The root Vue component name
|
||||
"CPE Provisioning", // The page title
|
||||
[
|
||||
// Pass API URLs and initial data to the frontend
|
||||
"CPE_PROV_API_GET_URL" => $this->getUrl("Cpeprovisioning", "apiGet"),
|
||||
"CPE_PROV_API_SAVE_URL" => $this->getUrl("Cpeprovisioning", "apiSave"),
|
||||
"CPE_PROV_PRINT_PDF_URL" => $this->getUrl("Cpeprovisioning", "printPDF"),
|
||||
"ORDER_URL" => $this->getUrl("Order"),
|
||||
"NETWORKS" => NetworkModel::getAll(),
|
||||
"ROUTER_OPTIONS" => [
|
||||
// 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
|
||||
['value' => 'FritzBox 6490 Cable', 'text' => 'FritzBox 6490 Cable (Inet, Phone, IPTV)'],
|
||||
],
|
||||
"ROUTER_SHIPPING_DATA" => [
|
||||
"TP-Link Archer C80" => ["weight" => 1, "length" => 35, "width" => 24, "height" => 8],
|
||||
"FritzBox 4040" => ["weight" => 1, "length" => 30, "width" => 24, "height" => 7],
|
||||
"FritzBox 7530" => ["weight" => 1, "length" => 26, "width" => 19, "height" => 7],
|
||||
"FritzBox 7590" => ["weight" => 1, "length" => 30, "width" => 24, "height" => 7],
|
||||
"FritzBox 6490 Cable" => ["weight" => 1, "length" => 30, "width" => 26, "height" => 8]
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function fixCpeData($data) {
|
||||
if (!$data) return [];
|
||||
$data->shipping = (bool)$data->shipping;
|
||||
$data->routerconfig_finished = (bool)$data->routerconfig_finished;
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function printPDFAction() {
|
||||
$order_id = $this->request->order_id;
|
||||
|
||||
$order = OrderModel::getOne($order_id);
|
||||
if (!$order) self::sendError("Order not found", 404);
|
||||
|
||||
$pdf_vars = [
|
||||
'firstline' => $order->owner->getCompanyOrName(),
|
||||
'secondline' => $order->owner->street,
|
||||
'thirdline' => $order->owner->zip . " " . $order->owner->city,
|
||||
'fourthline' => $order->owner->customer_number
|
||||
];
|
||||
|
||||
$pdf = new PdfForm("Cpeprovisioning/PDF_MAIN", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--page-height 32.5mm --page-width 57.5mm --margin-top 1mm --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8";
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . $filename . '"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -92,27 +92,24 @@ class FileController extends mfBaseController {
|
||||
$id = $this->request->id;
|
||||
$size = $this->request->size;
|
||||
|
||||
if (!is_numeric($id) || $id < 1) {
|
||||
http_response_code(400);
|
||||
self::returnJson(["error" => "Invalid File ID"]);
|
||||
return;
|
||||
}
|
||||
if (!is_numeric($id) || $id < 1) self::sendError("Invalid File ID");
|
||||
|
||||
$file = new File($id);
|
||||
if (!$file->id) {
|
||||
http_response_code(404);
|
||||
self::returnJson(["error" => "File record not found"]);
|
||||
return;
|
||||
}
|
||||
if (!$file->id) self::sendError("File record not found");
|
||||
|
||||
$originalPath = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}";
|
||||
if (!is_readable($originalPath)) {
|
||||
http_response_code(404);
|
||||
self::returnJson(["error" => "Physical file not found"]);
|
||||
return;
|
||||
}
|
||||
if (!is_readable($originalPath)) self::sendError("Physical file not found");
|
||||
|
||||
|
||||
$imageInfo = @getimagesize($originalPath);
|
||||
|
||||
if ($imageInfo === false && mime_content_type($originalPath) === 'application/pdf') {
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . ($file->orig_filename ?: $file->store_filename) . '"');
|
||||
readfile($originalPath);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($imageInfo === false) {
|
||||
$this->downloadAction();
|
||||
return;
|
||||
@@ -129,18 +126,13 @@ class FileController extends mfBaseController {
|
||||
|
||||
$cacheDir = TEMP_DIR . "/thumbnails";
|
||||
@mkdir($cacheDir, 0775, true);
|
||||
|
||||
$cachedPath = "{$cacheDir}/{$id}_{$size}." . pathinfo($originalPath, PATHINFO_EXTENSION);
|
||||
|
||||
if (!file_exists($cachedPath)) {
|
||||
$command = "convert " . escapeshellarg($originalPath) . " -resize " . escapeshellarg($sizeDimensions[$size]) . " " . escapeshellarg($cachedPath);
|
||||
exec($command, $output, $return_var);
|
||||
|
||||
if ($return_var !== 0) {
|
||||
http_response_code(500);
|
||||
self::returnJson(["error" => "Failed to create thumbnail."]);
|
||||
return;
|
||||
}
|
||||
if ($return_var !== 0) self::sendError("Failed to create thumbnail.");
|
||||
}
|
||||
|
||||
header('Content-Type: ' . $imageInfo['mime']);
|
||||
@@ -148,5 +140,4 @@ class FileController extends mfBaseController {
|
||||
readfile($cachedPath);
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -529,6 +529,13 @@ class OrderModel {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("add-where", $filter)) {
|
||||
$add_where = $filter['add-where'];
|
||||
if($add_where) {
|
||||
$where .= " AND ($add_where)";
|
||||
}
|
||||
}
|
||||
|
||||
//var_dump($filter, $where);exit;
|
||||
return $where;
|
||||
|
||||
@@ -141,7 +141,7 @@ class OrderProductModel
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function precache()
|
||||
public static function precache($where = false): array
|
||||
{
|
||||
$items = [];
|
||||
$db = FronkDB::singleton();
|
||||
@@ -162,7 +162,7 @@ class OrderProductModel
|
||||
";
|
||||
//mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
$res = $db->query($sql . ($where ? " WHERE $where" : ""));
|
||||
if ($db->num_rows($res)) {
|
||||
$oldProduct = "";
|
||||
$oldOrder = "";
|
||||
|
||||
@@ -156,7 +156,7 @@ class PreorderIFrameModel extends mfBaseModel
|
||||
return [
|
||||
'oaid' => $row['unit_oaid'] ?? $row['oaid'],
|
||||
'street' => $row['street'],
|
||||
'housenumber' => $row['housenumber'],
|
||||
'housenumber' => $row['hausnummer'],
|
||||
'hausnummer_id' => $row['hausnummer_id'],
|
||||
'wohneinheit_id' => $row['wohneinheit_id'],
|
||||
'building_type' => intval($row['building_type']),
|
||||
@@ -182,4 +182,4 @@ class PreorderIFrameModel extends mfBaseModel
|
||||
|
||||
return $parts ? implode(', ', $parts) : "Top {$counter}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
// RMLWorkorderController.php
|
||||
|
||||
class RMLWorkorderController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'RML Arbeitsaufträge';
|
||||
protected bool $createText = false; // Workorders are created automatically
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrag-Nr.'],
|
||||
['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => false]],
|
||||
['key' => 'companyName', 'text' => 'Zuständige Firma', 'modal' => false, 'table' => ['filter' => 'search']],
|
||||
['key' => 'status', 'text' => 'Status', 'modal' => ['items' => [
|
||||
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
|
||||
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
|
||||
['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'],
|
||||
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
|
||||
]], 'table' => ['filter' => 'iconSelect']],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
protected array $additionalJSVariables = ['RML_ADMIN' => '0', 'COMPANY_ID' => '0'];
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
// Assume 'RMLAdmin' is a permission.
|
||||
if ($this->user->can('RMLAdmin')) {
|
||||
$this->additionalJSVariables['RML_ADMIN'] = '1';
|
||||
} else {
|
||||
// If not an admin, find the user's associated company ID
|
||||
$company = RMLWorkorderCompanyModel::getAll(['addressId' => $this->user->address_id], 1);
|
||||
if ($company) {
|
||||
$this->additionalJSVariables['COMPANY_ID'] = $company[0]->id;
|
||||
} else {
|
||||
// If user is not an RML admin and not linked to a company, they see nothing.
|
||||
$this->sendError('Access Denied. You are not associated with a registered RML company.', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
// First, automatically create workorders for any new preorders with status 220.
|
||||
// In a production environment, this might be a separate cron job.
|
||||
$this->createWorkordersFromPreorders();
|
||||
|
||||
$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'];
|
||||
|
||||
// If user is a company, filter by their companyId
|
||||
if ($this->user->can('RMLAdmin') === false) {
|
||||
$company = RMLWorkorderCompanyModel::getAll(['addressId' => $this->user->address_id], 1);
|
||||
if($company) {
|
||||
$filters['companyId'] = $company[0]->id;
|
||||
}
|
||||
}
|
||||
|
||||
$workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
|
||||
$totalCount = RMLWorkorderModel::count($filters);
|
||||
|
||||
// Enhance rows with data from other tables
|
||||
$rows = [];
|
||||
foreach($workorders as $workorder) {
|
||||
$row = (array)$workorder;
|
||||
|
||||
$preorder = new Preorder($workorder->preorderId); // Placeholder for actual Preorder retrieval
|
||||
$anschlussadresse = '';
|
||||
if ($preorder->building_id) {
|
||||
$anschlussadresse = "{$preorder->building->street}<br />{$preorder->building->zip} {$preorder->building->city}";
|
||||
} elseif ($preorder->adb_hausnummer_id) {
|
||||
$anschlussadresse = "{$preorder->adb_hausnummer->strasse->name} {$preorder->adb_hausnummer->hausnummer}";
|
||||
if ($preorder->adb_hausnummer->stiege) {
|
||||
$anschlussadresse .= "/{$preorder->adb_hausnummer->stiege}";
|
||||
}
|
||||
if ($preorder->adb_wohneinheit_id && (string)$preorder->adb_wohneinheit) {
|
||||
$anschlussadresse .= "<br />{$preorder->adb_wohneinheit}";
|
||||
}
|
||||
$anschlussadresse .= "<br />{$preorder->adb_hausnummer->plz->plz} {$preorder->adb_hausnummer->ortschaft->name}";
|
||||
}
|
||||
|
||||
$kunde = ($preorder->company) ? $preorder->company : "{$preorder->firstname} {$preorder->lastname}";
|
||||
$kunde .= "<br />{$preorder->street}";
|
||||
if ($preorder->housenumber) {
|
||||
$kunde .= " {$preorder->housenumber}";
|
||||
}
|
||||
$kunde .= "<br />{$preorder->zip} {$preorder->city}";
|
||||
|
||||
$kontakt = ($preorder->phone) ? "{$preorder->phone}<br />" : '';
|
||||
$kontakt .= ($preorder->email) ? $preorder->email : '';
|
||||
|
||||
$row['preorderInfo'] = "Anschlussadresse: {$anschlussadresse}<br />" .
|
||||
"Kunde: {$kunde}<br />" .
|
||||
"Kontakt: {$kontakt}<br />" .
|
||||
"OAID: <span class='text-pink'>{$preorder->oaid}</span>";
|
||||
|
||||
// Get Company Name
|
||||
if($workorder->companyId) {
|
||||
$company = RMLWorkorderCompanyModel::get($workorder->companyId);
|
||||
$row['companyName'] = $company->name ?? 'N/A';
|
||||
} else {
|
||||
$row['companyName'] = 'Nicht zugewiesen';
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
$pagination = [
|
||||
'page' => $pagination['page'],
|
||||
'per_page' => $pagination['per_page'],
|
||||
'filtered_available' => $totalCount,
|
||||
'total_rows' => $totalCount,
|
||||
];
|
||||
|
||||
self::returnJson([
|
||||
'rows' => $rows,
|
||||
'pagination' => $pagination
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWorkordersFromPreorders() {
|
||||
// Fetch all active preorders where the status code is 220
|
||||
$newPreorders = PreorderModel::searchActive(['status_code' => 220]);
|
||||
|
||||
// If no new preorders are found, there's nothing to do
|
||||
if (empty($newPreorders)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through each preorder that needs a workorder
|
||||
foreach ($newPreorders as $preorder) {
|
||||
// Check if a workorder for this preorder already exists to prevent duplicates
|
||||
$existingWorkorder = RMLWorkorderModel::getFirst(['preorderId' => $preorder->id]);
|
||||
|
||||
// If no workorder exists, create a new one
|
||||
if (!$existingWorkorder) {
|
||||
RMLWorkorderModel::create([
|
||||
'preorderId' => $preorder->id,
|
||||
'status' => 'new',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id // The logged-in user creating the record
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function assignWorkorderAction() {
|
||||
if (!$this->user->can('RMLAdmin')) self::sendError("Permission denied.", 403);
|
||||
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderId']) || empty($post['companyId'])) {
|
||||
self::sendError("Required fields are missing.");
|
||||
}
|
||||
|
||||
if (!$rmlWorkorder = RMLWorkorderModel::get($post['workorderId'])) self::sendError("Workorder not found.");
|
||||
|
||||
RMLWorkorderModel::update(
|
||||
array_merge((array) $rmlWorkorder, [
|
||||
'id' => $post['workorderId'],
|
||||
'companyId' => $post['companyId'],
|
||||
'status' => 'assigned',
|
||||
'assignmentDate' => time(),
|
||||
'deadlineDate' => strtotime('+6 weeks')
|
||||
])
|
||||
);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Auftrag erfolgreich zugewiesen.']);
|
||||
}
|
||||
|
||||
protected function scheduleAppointmentAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderId']) || empty($post['appointmentDate'])) {
|
||||
self::sendError("Required fields are missing.");
|
||||
}
|
||||
|
||||
RMLWorkorderModel::update([
|
||||
'id' => $post['workorderId'],
|
||||
'appointmentDate' => $post['appointmentDate'],
|
||||
'status' => 'scheduled'
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
|
||||
}
|
||||
|
||||
protected function uploadDocumentationAction()
|
||||
{
|
||||
$file = $_FILES['file'] ?? null;
|
||||
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
|
||||
self::returnJson(['error' => 'File upload failed']);
|
||||
return;
|
||||
}
|
||||
|
||||
$workorderId = $_POST['workorderId'] ?? null;
|
||||
$description = $_POST['description'] ?? '';
|
||||
$documentType = $_POST['documentType'] ?? 'general';
|
||||
|
||||
if(!$workorderId) {
|
||||
self::returnJson(['error' => 'Workorder ID is missing.']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$uploaded = mfUpload::handleFormUpload("file", false, "/RMLWorkorder");
|
||||
|
||||
RMLWorkorderDocumentationModel::create([
|
||||
'workorderId' => $workorderId,
|
||||
'fileId' => $uploaded->id,
|
||||
'description' => $description,
|
||||
'documentType' => $documentType,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
|
||||
// Set status to 'documented' if it was 'scheduled' or 'assigned'
|
||||
$workorder = RMLWorkorderModel::get($workorderId);
|
||||
if(in_array($workorder->status, ['assigned', 'scheduled'])) {
|
||||
RMLWorkorderModel::update(['id' => $workorderId, 'status' => 'documented']);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'fileId' => $uploaded->id, 'fileName' => $file['name']]);
|
||||
} catch (Exception $e) {
|
||||
self::returnJson(['error' => 'Upload error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getDocumentationAction() {
|
||||
if(empty($this->request->workorderId)) self::sendError("Workorder ID missing.");
|
||||
|
||||
$docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId]);
|
||||
// Enhance with file names
|
||||
foreach($docs as $doc) {
|
||||
$file = new File($doc->fileId);
|
||||
$doc->fileName = $file->filename;
|
||||
}
|
||||
self::returnJson($docs);
|
||||
}
|
||||
|
||||
protected function completeWorkorderAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if(empty($post['workorderId'])) self::sendError("Workorder ID missing.");
|
||||
|
||||
$workorder = RMLWorkorderModel::get($post['workorderId']);
|
||||
if(!$workorder) self::sendError("Workorder not found.");
|
||||
|
||||
// Update Preorder status to 245
|
||||
// PreorderModel::update(['id' => $workorder->preorderId, 'status_code' => 245]);
|
||||
|
||||
// Update Workorder status
|
||||
RMLWorkorderModel::update([
|
||||
'id' => $workorder->id,
|
||||
'status' => 'completed'
|
||||
]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Auftrag abgeschlossen. Preorder wurde aktualisiert.']);
|
||||
}
|
||||
|
||||
// Action to get companies for the assignment modal
|
||||
protected function getCompaniesAction() {
|
||||
if(!$this->user->can('RMLAdmin')) self::sendError("Permission denied.", 403);
|
||||
$companies = RMLWorkorderCompanyModel::getAll();
|
||||
$items = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies);
|
||||
self::returnJson($items);
|
||||
}
|
||||
}
|
||||
157
application/RMLWorkorderAdmin/RMLWorkorderAdminController.php
Normal file
157
application/RMLWorkorderAdmin/RMLWorkorderAdminController.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
// RMLWorkorderAdminController.php
|
||||
|
||||
class RMLWorkorderAdminController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'RML Arbeitsaufträge (Admin)';
|
||||
protected bool $createText = false;
|
||||
protected array $permissionCheck = ['RMLAdmin'];
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]],
|
||||
['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => 'search']],
|
||||
['key' => 'companyName', 'text' => 'Zuständige Firma', 'modal' => false, 'table' => ['filter' => 'search']],
|
||||
['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [
|
||||
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
|
||||
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
|
||||
['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'],
|
||||
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
|
||||
]]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
protected function indexAction()
|
||||
{
|
||||
$this->createWorkordersFromPreorders();
|
||||
Helper::renderVue($this, 'RMLWorkorderAdmin', $this->headerTitle, [
|
||||
"CRUD_CONFIG" => $this->getCrudConfig(),
|
||||
"TABLE_URL" => $this::getUrl("RMLWorkorderAdmin/get"),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$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'];
|
||||
|
||||
// Custom filter logic for preorderInfo
|
||||
if (!empty($filters['preorderInfo'])) {
|
||||
$searchTerm = $filters['preorderInfo'];
|
||||
unset($filters['preorderInfo']);
|
||||
|
||||
// This is a simplified search. A more robust implementation might involve a full-text search or a more complex query.
|
||||
$preorders = PreorderModel::getAll(['firstname|lastname|company|oaid' => $searchTerm]);
|
||||
$preorderIds = array_map(fn($p) => $p->id, $preorders);
|
||||
|
||||
if (!empty($preorderIds)) {
|
||||
$filters['preorderId'] = $preorderIds;
|
||||
} else {
|
||||
// No preorders found, so no workorders will be found
|
||||
$filters['id'] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
$workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
|
||||
$totalCount = RMLWorkorderModel::count($filters);
|
||||
|
||||
$rows = [];
|
||||
foreach($workorders as $workorder) {
|
||||
$row = (array)$workorder;
|
||||
|
||||
$preorder = new Preorder($workorder->preorderId);
|
||||
$anschlussadresse = 'N/A';
|
||||
if ($preorder->adb_hausnummer_id) {
|
||||
$hn = $preorder->adb_hausnummer;
|
||||
$anschlussadresse = "{$hn->strasse->name} {$hn->hausnummer}";
|
||||
if ($hn->stiege) $anschlussadresse .= "/{$hn->stiege}";
|
||||
if ($preorder->adb_wohneinheit_id) $anschlussadresse .= " / WE: {$preorder->adb_wohneinheit->bezeichner}";
|
||||
$anschlussadresse .= ", {$hn->plz->plz} {$hn->ortschaft->name}";
|
||||
}
|
||||
|
||||
$kunde = ($preorder->company) ?: "{$preorder->firstname} {$preorder->lastname}";
|
||||
|
||||
$row['preorderInfo'] = "<strong>Kunde:</strong> {$kunde}<br>" .
|
||||
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
|
||||
"<strong>OAID:</strong> <span class='text-pink'>{$preorder->oaid}</span>";
|
||||
|
||||
if($workorder->companyId) {
|
||||
$company = RMLWorkorderCompanyModel::get($workorder->companyId);
|
||||
$row['companyName'] = $company->name ?? 'N/A';
|
||||
} else {
|
||||
$row['companyName'] = 'Nicht zugewiesen';
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'rows' => $rows,
|
||||
'pagination' => [
|
||||
'page' => $pagination['page'],
|
||||
'per_page' => $pagination['per_page'],
|
||||
'total_rows' => $totalCount,
|
||||
'total_pages' => ceil($totalCount / $pagination['per_page']),
|
||||
'filtered_available' => $totalCount
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWorkordersFromPreorders() {
|
||||
$newPreorders = PreorderModel::searchActive(['status_code' => 220]);
|
||||
if (empty($newPreorders)) return;
|
||||
|
||||
foreach ($newPreorders as $preorder) {
|
||||
if (!RMLWorkorderModel::getFirst(['preorderId' => $preorder->id])) {
|
||||
RMLWorkorderModel::create([
|
||||
'preorderId' => $preorder->id,
|
||||
'status' => 'new',
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function assignWorkorderAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderId']) || empty($post['companyId'])) self::sendError("Required fields are missing.");
|
||||
|
||||
$workorder = RMLWorkorderModel::get($post['workorderId']);
|
||||
if (!$workorder) self::sendError("Workorder not found.");
|
||||
|
||||
$workorder->companyId = $post['companyId'];
|
||||
$workorder->status = 'assigned';
|
||||
$workorder->assignmentDate = time();
|
||||
$workorder->deadlineDate = strtotime('+6 weeks');
|
||||
|
||||
RMLWorkorderModel::update((array)$workorder);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Auftrag erfolgreich zugewiesen.']);
|
||||
}
|
||||
|
||||
protected function getDocumentationAction() {
|
||||
if(empty($this->request->workorderId)) self::sendError("Workorder ID missing.");
|
||||
|
||||
$docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'DESC']);
|
||||
$users = UserModel::search(['employee' => true]);
|
||||
$userMap = array_reduce($users, fn($carry, $user) => $carry + [$user->id => $user->name], []);
|
||||
|
||||
foreach($docs as $doc) {
|
||||
$file = new File($doc->fileId);
|
||||
$doc->fileName = $file->orig_filename ?? $file->filename;
|
||||
$doc->userName = $userMap[$doc->createBy] ?? 'Unbekannt';
|
||||
}
|
||||
self::returnJson($docs);
|
||||
}
|
||||
|
||||
protected function getCompaniesAction() {
|
||||
$companies = RMLWorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
|
||||
$items = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies);
|
||||
self::returnJson($items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
// RMLWorkorderCompanyController.php
|
||||
|
||||
class RMLWorkorderCompanyController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'Meine Arbeitsaufträge';
|
||||
protected bool $createText = false;
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]],
|
||||
['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false, 'filter' => 'search']],
|
||||
['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [
|
||||
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
|
||||
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
|
||||
['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'],
|
||||
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
|
||||
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
|
||||
]]],
|
||||
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
|
||||
protected array $additionalJSVariables = ['COMPANY_ID' => '0'];
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
$company = RMLWorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
if ($company) {
|
||||
$this->additionalJSVariables['COMPANY_ID'] = $company->id;
|
||||
} else {
|
||||
$this->sendError('Access Denied. You are not associated with a registered RML company.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
protected function indexAction()
|
||||
{
|
||||
Helper::renderVue($this, 'RMLWorkorderCompany', $this->headerTitle, [
|
||||
"CRUD_CONFIG" => $this->getCrudConfig(),
|
||||
"TABLE_URL" => $this::getUrl("RMLWorkorderCompany/get"),
|
||||
"COMPANY_ID" => $this->additionalJSVariables['COMPANY_ID'],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getAction()
|
||||
{
|
||||
$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'];
|
||||
|
||||
$company = RMLWorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
|
||||
if(!$company) self::sendError("Company not found for user.", 403);
|
||||
$filters['companyId'] = $company->id;
|
||||
|
||||
if (!empty($filters['preorderInfo'])) {
|
||||
$searchTerm = $filters['preorderInfo'];
|
||||
|
||||
//todo: fix this preordermodel search shit
|
||||
$preorders = PreorderModel::getAll(['firstname|lastname|company|oaid' => $searchTerm]);
|
||||
$preorderIds = array_map(fn($p) => $p->id, $preorders);
|
||||
|
||||
if (!empty($preorderIds)) {
|
||||
$filters['preorderId'] = $preorderIds;
|
||||
} else {
|
||||
$filters['id'] = -1;
|
||||
}
|
||||
}
|
||||
unset($filters['preorderInfo']);
|
||||
// only show workorders that are assigned to the company and have the status assigned or scheduled
|
||||
$filters['status'] = ['assigned', 'scheduled'];
|
||||
$filters['companyId'] = $company->id;
|
||||
|
||||
|
||||
$workorders = RMLWorkorderModel::getAll($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order);
|
||||
$totalCount = RMLWorkorderModel::count($filters);
|
||||
|
||||
$rows = [];
|
||||
foreach($workorders as $workorder) {
|
||||
$row = (array)$workorder;
|
||||
$row['preorderInfo'] = $this->getPreorderInfoText($workorder->preorderId);
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
self::returnJson([
|
||||
'rows' => $rows,
|
||||
'pagination' => [
|
||||
'page' => $pagination['page'],
|
||||
'per_page' => $pagination['per_page'],
|
||||
'total_rows' => $totalCount,
|
||||
'total_pages' => ceil($totalCount / $pagination['per_page']),
|
||||
'filtered_available' => $totalCount
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function getWorkorderByIdAction() {
|
||||
$id = $this->request->id;
|
||||
if(!$id) self::sendError("ID missing");
|
||||
|
||||
$workorder = RMLWorkorderModel::get($id);
|
||||
if(!$workorder) self::sendError("Workorder not found");
|
||||
|
||||
$workorder->preorderInfo = $this->getPreorderInfoText($workorder->preorderId);
|
||||
|
||||
self::returnJson((array) $workorder);
|
||||
}
|
||||
|
||||
private function getPreorderInfoText($preorderId) {
|
||||
$preorder = new Preorder($preorderId);
|
||||
$anschlussadresse = 'N/A';
|
||||
if ($preorder->adb_hausnummer_id) {
|
||||
$hn = $preorder->adb_hausnummer;
|
||||
$anschlussadresse = "{$hn->strasse->name} {$hn->hausnummer}";
|
||||
if ($hn->stiege) $anschlussadresse .= "/{$hn->stiege}";
|
||||
if ($preorder->adb_wohneinheit_id) $anschlussadresse .= " / WE: {$preorder->adb_wohneinheit->bezeichner}";
|
||||
$anschlussadresse .= ", {$hn->plz->plz} {$hn->ortschaft->name}";
|
||||
}
|
||||
|
||||
$kunde = ($preorder->company) ?: "{$preorder->firstname} {$preorder->lastname}";
|
||||
|
||||
return "<strong>Kunde:</strong> {$kunde}<br>" .
|
||||
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
|
||||
"<strong>Kontakt:</strong> {$preorder->phone} / {$preorder->email}<br>" .
|
||||
"<strong>OAID:</strong> <span class='text-pink'>{$preorder->oaid}</span>";
|
||||
}
|
||||
|
||||
protected function scheduleAppointmentAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if (empty($post['workorderId']) || empty($post['appointmentDate'])) self::sendError("Required fields are missing.");
|
||||
|
||||
$workorder = RMLWorkorderModel::get($post['workorderId']);
|
||||
if(!$workorder) self::sendError("Workorder not found");
|
||||
|
||||
$workorder->appointmentDate = $post['appointmentDate'];
|
||||
$workorder->status = 'scheduled';
|
||||
RMLWorkorderModel::update((array)$workorder);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
|
||||
}
|
||||
|
||||
protected function uploadDocumentationAction()
|
||||
{
|
||||
if (empty($_FILES['files']) || empty($_POST['workorderId'])) {
|
||||
self::returnJson(['error' => 'Required data is missing.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$workorderId = $_POST['workorderId'];
|
||||
$description = $_POST['description'] ?? '';
|
||||
$documentType = $_POST['documentType'] ?? 'general';
|
||||
$files = $_FILES['files'];
|
||||
$uploadCount = 0;
|
||||
|
||||
foreach ($files['name'] as $index => $name) {
|
||||
if ($files['error'][$index] === UPLOAD_ERR_OK) {
|
||||
$_FILES['file'] = [
|
||||
'name' => $files['name'][$index],
|
||||
'type' => $files['type'][$index],
|
||||
'tmp_name' => $files['tmp_name'][$index],
|
||||
'error' => $files['error'][$index],
|
||||
'size' => $files['size'][$index]
|
||||
];
|
||||
|
||||
try {
|
||||
$uploaded = mfUpload::handleFormUpload("file", false, "/RMLWorkorder");
|
||||
RMLWorkorderDocumentationModel::create([
|
||||
'workorderId' => $workorderId,
|
||||
'fileId' => $uploaded->id,
|
||||
'description' => $description,
|
||||
'documentType' => $documentType,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id
|
||||
]);
|
||||
$uploadCount++;
|
||||
} catch (Exception $e) {
|
||||
var_dump($e->getMessage());exit;
|
||||
// Log error but continue with other files
|
||||
error_log("File upload failed for $name: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => "$uploadCount Datei(en) erfolgreich hochgeladen."]);
|
||||
}
|
||||
|
||||
protected function getDocumentationAction() {
|
||||
if(empty($this->request->workorderId)) self::sendError("Workorder ID missing.");
|
||||
|
||||
// Order by creation date to ensure consistent numbering (_1, _2, etc.)
|
||||
$docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'ASC']);
|
||||
|
||||
$responseDocs = [];
|
||||
$typeCounts = [];
|
||||
|
||||
$translationMap = [
|
||||
'photo_before' => 'Foto vorher',
|
||||
'photo_during' => 'Foto währenddessen',
|
||||
'photo_after' => 'Foto nachher',
|
||||
'measurement_protocol' => 'Messprotokoll',
|
||||
'customer_signature' => 'Kundenunterschrift',
|
||||
];
|
||||
|
||||
foreach($docs as $doc) {
|
||||
$file = new File($doc->fileId);
|
||||
|
||||
// Increment counter for the specific document type
|
||||
$documentTypeKey = $doc->documentType;
|
||||
if (!isset($typeCounts[$documentTypeKey])) {
|
||||
$typeCounts[$documentTypeKey] = 1;
|
||||
} else {
|
||||
$typeCounts[$documentTypeKey]++;
|
||||
}
|
||||
|
||||
// Construct the new filename using the original key
|
||||
$originalFilename = $file->orig_filename ?? $file->filename;
|
||||
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
|
||||
$translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
|
||||
$newFilename = "{$translatedType} {$typeCounts[$documentTypeKey]}." . strtolower($extension);
|
||||
|
||||
// Get the translated text, with a fallback to the original key
|
||||
|
||||
// Build the response object with 'id' mapped from 'fileId' and the translated type
|
||||
$responseDocs[] = [
|
||||
'id' => $doc->fileId,
|
||||
'fileName' => $newFilename,
|
||||
'documentType' => $documentTypeKey,
|
||||
'mimetype' => $file->mimetype,
|
||||
];
|
||||
}
|
||||
self::returnJson($responseDocs);
|
||||
}
|
||||
protected function completeWorkorderAction() {
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
if(empty($post['workorderId'])) self::sendError("Workorder ID missing.");
|
||||
|
||||
$workorder = RMLWorkorderModel::get($post['workorderId']);
|
||||
if(!$workorder) self::sendError("Workorder not found.");
|
||||
|
||||
$workorder->status = 'documented';
|
||||
RMLWorkorderModel::update((array)$workorder);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Auftrag abgeschlossen.']);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ class WarehouseArticleController extends TTCrud {
|
||||
['key' => 'isSerialDocumentation', 'text' => 'Seriennummern', 'required' => false,'modal' => ['type' => 'checkbox'], 'table' => false],
|
||||
['key' => 'isEShop', 'text' => 'Ist E-Shop', 'required' => false,'modal' => ['type' => 'checkbox'], 'table' => false],
|
||||
['key' => 'isEShopHide', 'text' => 'E-Shop Versteckt', 'required' => false,'modal' => ['type' => 'checkbox'], 'table' => false],
|
||||
['key' => 'isSbidiShop', 'text' => 'Ist SBIDI-Shop', 'required' => false,'modal' => ['type' => 'checkbox'], 'table' => false],
|
||||
['key' => 'isSbidiShopHide', 'text' => 'SBIDI-Shop Versteckt', 'required' => false,'modal' => ['type' => 'checkbox'], 'table' => false],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 8]]
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ class WarehouseArticleModel extends TTCrudBaseModel {
|
||||
public int $criticalAmount;
|
||||
public ?int $isEShop;
|
||||
public ?int $isEShopHide;
|
||||
public ?int $isSbidiShop;
|
||||
public ?int $isSbidiShopHide;
|
||||
public string $unit;
|
||||
public ?int $isSerialDocumentation;
|
||||
public int $revenueAccount;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @property mixed|null $name
|
||||
*/
|
||||
class WarehouseArticlePacket extends mfBaseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -13,20 +13,25 @@ class WarehouseArticlePacketController extends TTCrud {
|
||||
['key' => 'overrideSellPrice', 'text' => 'Überschriebener Verkaufspreis', 'required' => false, 'modal' => ['type' => 'number'], 'table' => false],
|
||||
['key' => 'calculatedSellPrice', 'text' => 'Verkaufspreis', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
['key' => 'subItems', 'text' => 'Unterartikel', 'required' => true],
|
||||
['key' => 'isEShop', 'text' => 'E-Shop', 'required' => false, 'modal' => ['type' => 'checkbox', 'items' => [['value' => 1, 'icon' => 'fas fa-check-circle text-success', 'text' => 'Ja'], ['value' => 0, 'icon' => 'fas fa-times-circle text-danger', 'text' => 'Nein']]], 'table' => ['filter' => 'iconSelect']],
|
||||
['key' => 'isEShopHide', 'text' => 'Hide', 'required' => false, 'modal' => ['type' => 'checkbox', 'items' => [['value' => 1, 'icon' => 'fas fa-check-circle text-success', 'text' => 'Ja'], ['value' => 0, 'icon' => 'fas fa-times-circle text-danger', 'text' => 'Nein']]], 'table' => ['filter' => 'iconSelect']],
|
||||
['key' => 'isSbidiShop', 'text' => 'S-Shop', 'required' => false, 'modal' => ['type' => 'checkbox', 'items' => [['value' => 1, 'icon' => 'fas fa-check-circle text-success', 'text' => 'Ja'], ['value' => 0, 'icon' => 'fas fa-times-circle text-danger', 'text' => 'Nein']]], 'table' => ['filter' => 'iconSelect']],
|
||||
['key' => 'isSbidiShopHide', 'text' => 'Hide', 'required' => false, 'modal' => ['type' => 'checkbox', 'items' => [['value' => 1, 'icon' => 'fas fa-check-circle text-success', 'text' => 'Ja'], ['value' => 0, 'icon' => 'fas fa-times-circle text-danger', 'text' => 'Nein']]], 'table' => ['filter' => 'iconSelect']],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
|
||||
];
|
||||
// @formatter:on
|
||||
|
||||
protected array $infoMessages = ['create' => 'Artikel-Paket wurde erstellt',
|
||||
'update' => 'Artikel-Paket wurde aktualisiert',
|
||||
'delete' => 'Artikel-Paket wurde gelöscht',
|
||||
'noChanges' => 'Keine Änderungen'];
|
||||
'update' => 'Artikel-Paket wurde aktualisiert',
|
||||
'delete' => 'Artikel-Paket wurde gelöscht',
|
||||
'noChanges' => 'Keine Änderungen'];
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
$articles = array_map(function ($article) {
|
||||
return ['value' => $article->id, 'text' => $article->title];
|
||||
}, WarehouseArticleModel::getAll(
|
||||
['isEShop' => 1],
|
||||
// Filter articles based on the user's address_id for the shop context
|
||||
($this->user->address_id === 209) ? ['isEShop' => 1] : (($this->user->address_id === 210) ? ['isSbidiShop' => 1] : []),
|
||||
));
|
||||
|
||||
$this->columns[6]['modal']['items'] = $articles;
|
||||
@@ -35,9 +40,21 @@ class WarehouseArticlePacketController extends TTCrud {
|
||||
//TODO: make this so it does not update all packets at the same time
|
||||
protected function updatePacketPricesAction() {
|
||||
$packets = WarehouseArticlePacketModel::getAll();
|
||||
$articles = WarehouseArticleModel::getAll(['isEShop' => 1]);
|
||||
|
||||
// packet has $calculatedSellPrice for this but when overrideSellPrice is set, it should be used
|
||||
// Determine which shop's articles to use for price calculation based on the current user's shop context
|
||||
// This is a simplification; in a multi-tenant system, this might need to be more robust,
|
||||
// e.g., by iterating through all possible shop types or having a dedicated price calculation service.
|
||||
$shopPriceTitle = '';
|
||||
$articleFilter = [];
|
||||
if ($this->user->address_id === 209) {
|
||||
$shopPriceTitle = 'Energie Steiermark';
|
||||
$articleFilter['isEShop'] = 1;
|
||||
} elseif ($this->user->address_id === 210) {
|
||||
$shopPriceTitle = 'Sbidi';
|
||||
$articleFilter['isSbidiShop'] = 1;
|
||||
}
|
||||
|
||||
$articles = WarehouseArticleModel::getAll($articleFilter);
|
||||
|
||||
foreach ($packets as $packet) {
|
||||
if ($packet->overrideSellPrice) {
|
||||
@@ -47,20 +64,29 @@ class WarehouseArticlePacketController extends TTCrud {
|
||||
$calculatedSellPrice = 0;
|
||||
|
||||
foreach ($subItems as $subItem) {
|
||||
$article = WarehouseArticleModel::get($subItem->id);
|
||||
$cheapestSellPrices = json_decode($article->cheapestSellPrice, true);
|
||||
// find in array cheapestSellPrices by title === 'Energie Steiermark' and get the price
|
||||
$articlePrice = array_values(array_filter($cheapestSellPrices, function ($cheapestSellPrice) {
|
||||
return $cheapestSellPrice['title'] === 'Energie Steiermark';
|
||||
}));
|
||||
$article = null;
|
||||
// Find the article by ID from the already fetched articles to avoid N+1 queries
|
||||
foreach ($articles as $a) {
|
||||
if ($a->id == $subItem->id) {
|
||||
$article = $a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$articlePrice = $articlePrice[0]['price'] ?? 0;
|
||||
|
||||
$calculatedSellPrice += $subItem->amount * $articlePrice;
|
||||
if ($article) {
|
||||
$cheapestSellPrices = json_decode($article->cheapestSellPrice, true);
|
||||
$articlePrice = 0;
|
||||
// Find price for the specific shop
|
||||
$foundPrice = array_values(array_filter($cheapestSellPrices, function ($cheapestSellPrice) use ($shopPriceTitle) {
|
||||
return $cheapestSellPrice['title'] === $shopPriceTitle;
|
||||
}));
|
||||
$articlePrice = $foundPrice[0]['price'] ?? 0;
|
||||
$calculatedSellPrice += $subItem->amount * $articlePrice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WarehouseArticlePacketModel::update(array_merge(get_object_vars($packet), ['calculatedSellPrice' => $calculatedSellPrice]));
|
||||
|
||||
WarehouseArticlePacketModel::update(array_merge(get_object_vars($packet), ['calculatedSellPrice' => $calculatedSellPrice]));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -9,4 +9,8 @@ class WarehouseArticlePacketModel extends TTCrudBaseModel {
|
||||
public ?float $overrideSellPrice;
|
||||
public ?float $calculatedSellPrice;
|
||||
public string $subItems;
|
||||
}
|
||||
public ?int $isEShop; // New field for Energie Steiermark shop visibility
|
||||
public ?int $isEShopHide; // New field to hide from Energie Steiermark shop
|
||||
public ?int $isSbidiShop; // New field for Sbidi shop visibility
|
||||
public ?int $isSbidiShopHide; // New field to hide from Sbidi shop
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @property mixed|null $name
|
||||
*/
|
||||
class WarehouseEShop extends mfBaseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,66 +1,73 @@
|
||||
<?php
|
||||
// Warrenkorb löschen
|
||||
// File Upload ermöglichen
|
||||
// Hide Articles
|
||||
|
||||
|
||||
class WarehouseEShopController extends TTCrud {
|
||||
protected string $headerTitle = 'Energie Steiermark Shop';
|
||||
protected bool $createText = false;
|
||||
//@formatter:off
|
||||
|
||||
protected array $columns = [
|
||||
['key' => 'title', 'text' => 'Artikel', 'priority' => 11],
|
||||
['key' => 'category', 'text' => 'Kategorie', 'table' => false],
|
||||
['key' => 'price', 'text' => 'Preis', 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-right']],
|
||||
['key' => 'price', 'text' => 'Preis', 'table' => false],
|
||||
['key' => 'amount', 'text' => 'Menge', 'table' => ['filter' => false, 'sortable' => false, 'class' => 'p-0 width-80'], 'priority' => 9],
|
||||
['key' => 'add', 'text' => 'Hinzufügen', 'table' => ['filter' => false, 'sortable' => false, 'class' => 'width-120 text-center'], 'priority' => 5000]
|
||||
];
|
||||
|
||||
//@formatter:on
|
||||
protected array $permissionCheck = ['WarehouseEShop'];
|
||||
|
||||
protected array $infoMessages = [
|
||||
'create' => 'Not possible',
|
||||
'update' => 'Not possible',
|
||||
'delete' => 'Not possible',
|
||||
'noChanges' => 'Keine Änderungen',
|
||||
];
|
||||
protected function afterInit() {
|
||||
if (!$this->user->isAdmin()) return;
|
||||
if (!$this->user->getFlag('WarehouseSelectedShop') && !isset($_GET['shop'])) self::sendError("Bitte wählen Sie einen Shop aus.");
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
if (!$this->user->can('WarehouseAdmin')) {
|
||||
$this->columns[2]['table'] = false;
|
||||
if (in_array($_GET['shop'], ['e', 'sbidi'])) {
|
||||
$flag = new WorkerFlag($this->user->id, 'WarehouseSelectedShop');
|
||||
$flag->value($_GET['shop']);
|
||||
$flag->save();
|
||||
$this->user->address_id = ($_GET['shop'] === 'e') ? '209' : '9633';
|
||||
return;
|
||||
}
|
||||
|
||||
$this->user->address_id = ($this->user->getFlag('WarehouseSelectedShop')->value() === 'e') ? '209' : '9633';
|
||||
}
|
||||
|
||||
|
||||
protected function prepareCrudConfig() {
|
||||
if (!in_array(intval($this->user->address_id), [209, 9633])) self::sendError("Keine Berechtigung für diesen Shop");
|
||||
|
||||
$this->additionalJSVariables['userAddressId'] = $this->user->address_id ?? null;
|
||||
$this->headerTitle = $this->user->address_id == 209 ? 'Energie Steiermark Shop' : 'SBIDI Shop';
|
||||
}
|
||||
|
||||
public function getAction() {
|
||||
if (!in_array(intval($this->user->address_id), [209, 9633])) self::sendError("Keine Berechtigung für diesen Shop");
|
||||
|
||||
$filter = $this->postData['filters'] ?? [];
|
||||
$order = $this->postData['order'] ?? ['key' => null, 'order' => 'ASC'];
|
||||
$page = $this->postData['pagination']['page'] ?? 1;
|
||||
$perPage = $this->postData['pagination']['per_page'] ?? 10;
|
||||
|
||||
$warehouseArticleFilter = $filter;
|
||||
$warehouseArticleFilter['isEShop'] = 1;
|
||||
$warehouseArticleFilter['isEShopHide'] = 0;
|
||||
|
||||
$warehouseArticles = WarehouseArticleModel::getAll($warehouseArticleFilter, null, 0, $order);
|
||||
$warehouseArticlesTotal = WarehouseArticleModel::count(['isEShop' => 1, 'isEShopHide' => 0]);
|
||||
$warehouseArticlesAvailable = WarehouseArticleModel::count($warehouseArticleFilter);
|
||||
$shopType = (intval($this->user->address_id) === 209) ? 'EShop' : 'SbidiShop';
|
||||
$filter["is{$shopType}"] = 1;
|
||||
$filter["is{$shopType}Hide"] = 0;
|
||||
|
||||
$warehouseArticles = WarehouseArticleModel::getAll($filter, null, 0, $order);
|
||||
$warehousePackets = WarehouseArticlePacketModel::getAll($filter, null, 0, $order);
|
||||
$warehousePacketsTotal = WarehouseArticlePacketModel::count();
|
||||
$warehousePacketsAvailable = WarehouseArticlePacketModel::count($filter);
|
||||
|
||||
$filteredAvailable = $warehouseArticlesAvailable + $warehousePacketsAvailable;
|
||||
$totalRows = $warehouseArticlesTotal + $warehousePacketsTotal;
|
||||
$filteredAvailable = WarehouseArticlePacketModel::count($filter) + WarehouseArticleModel::count($filter);
|
||||
|
||||
$rows = [...$warehouseArticles, ...$warehousePackets];
|
||||
|
||||
$rows = array_slice($rows, ($page - 1) * $perPage, $perPage);
|
||||
usort($rows, function($a, $b) { return strcmp($a->title, $b->title); });
|
||||
|
||||
self::returnJson(["rows" => $rows,
|
||||
"pagination" => ["page" => $page,
|
||||
"total_pages" => ceil($filteredAvailable / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => $filteredAvailable,
|
||||
"total_rows" => $totalRows]]);
|
||||
self::returnJson([
|
||||
"rows" => array_slice($rows, ($page - 1) * $perPage, $perPage),
|
||||
"pagination" => [
|
||||
"page" => $page,
|
||||
"total_pages" => ceil($filteredAvailable / $perPage),
|
||||
"per_page" => $perPage,
|
||||
"filtered_available" => $filteredAvailable,
|
||||
"total_rows" => $filteredAvailable
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @property mixed|null $name
|
||||
*/
|
||||
class WarehouseEShopOrder extends mfBaseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
protected array $columns = [
|
||||
['key' => 'id', 'text' => 'ID', 'modal' => false],
|
||||
['key' => 'extRef', 'text' => 'Externe Referenz', 'required' => true],
|
||||
['key' => 'addressId', 'text' => 'Shop', 'modal' => false, 'table' => ['filter' => 'select'], 'type' => 'select', 'items' => []], // New column for address ID
|
||||
['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [['value' => 'new', 'text' => 'Neu'], ['value' => 'accepted', 'text' => 'An Lieferant übergeben'], ['value' => 'acceptedInternally', 'text' => 'Interne verarbeitung'], ['value' => 'sent', 'text' => 'Gesendet'], ['value' => 'done', 'text' => 'Erledigt'],]], 'table' => ['filter' => 'select']],
|
||||
['key' => 'shippingNoteStatus', 'text' => 'LS-Status', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'order' => false]],
|
||||
['key' => 'deliveryMode', 'text' => 'Liefermodus', 'required' => true, 'modal' => ['type' => 'select', 'items' => [['value' => 'singleAddress', 'text' => 'Einzelne Adresse']]]],
|
||||
@@ -69,6 +70,16 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
|
||||
$createByIndex = array_search('createBy', array_column($this->columns, 'key'));
|
||||
$this->columns[$createByIndex]['modal']['items'] = $users;
|
||||
|
||||
// Add options for the new addressId column filter
|
||||
$addressIdColumnIndex = array_search('addressId', array_column($this->columns, 'key'));
|
||||
if ($addressIdColumnIndex !== false) {
|
||||
$this->columns[$addressIdColumnIndex]['items'] = [
|
||||
['value' => 209, 'text' => 'Energie Steiermark'],
|
||||
['value' => 9633, 'text' => 'SBIDI'],
|
||||
];
|
||||
$this->columns[$addressIdColumnIndex]['modal']['items'] = $this->columns[$addressIdColumnIndex]['items'];
|
||||
}
|
||||
}
|
||||
|
||||
protected function createShippingNote() {
|
||||
@@ -81,8 +92,8 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
$existingShippingNote = WarehouseShippingNoteModel::getAll(['eShopOrderId' => $id]);
|
||||
if (!empty($existingShippingNote)) {
|
||||
self::returnJson(['success' => false,
|
||||
'message' => 'Für diese Bestellung existiert bereits ein Lieferschein',
|
||||
'shippingNoteId' => $existingShippingNote[0]->id]);
|
||||
'message' => 'Für diese Bestellung existiert bereits ein Lieferschein',
|
||||
'shippingNoteId' => $existingShippingNote[0]->id]);
|
||||
die();
|
||||
}
|
||||
|
||||
@@ -101,11 +112,19 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
$articleTitle = $item->articleId ? $articles[$article]->title : $articlePackets[$articlePacket]->title;
|
||||
$quantity = $item->quantity;
|
||||
$price = 0;
|
||||
|
||||
$priceTitle = '';
|
||||
if ($order->addressId === 209) {
|
||||
$priceTitle = 'Energie Steiermark';
|
||||
} elseif ($order->addressId === 9633) {
|
||||
$priceTitle = 'SBIDI';
|
||||
}
|
||||
|
||||
if ($item->articleId) {
|
||||
$cheapestSellPrice = json_decode($articles[$article]->cheapestSellPrice, true);
|
||||
foreach ($cheapestSellPrice as $price) {
|
||||
if ($price['title'] === 'Energie Steiermark') {
|
||||
$price = $price['price'];
|
||||
foreach ($cheapestSellPrice as $p) {
|
||||
if ($p['title'] === $priceTitle) {
|
||||
$price = $p['price'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -126,20 +145,20 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
|
||||
$positions = json_encode($positions);
|
||||
|
||||
$shippingNoteId = WarehouseShippingNoteModel::create(['billingAddressId' => 3265,
|
||||
'deliveryAddressName' => $order->deliveryAddressName,
|
||||
'deliveryAddressLine' => $order->deliveryAddressLine,
|
||||
'deliveryAddressPLZ' => $order->deliveryAddressPLZ,
|
||||
'deliveryAddressCity' => $order->deliveryAddressCity,
|
||||
'deliveryAddressEMail' => '',
|
||||
'note' => 'Erstellung aus Energie Steiermark Shop Bestellung #' . $id,
|
||||
'status' => 'new',
|
||||
'positions' => $positions,
|
||||
'textElements' => '[]',
|
||||
'hoursEntries' => '[]',
|
||||
'eShopOrderId' => $id,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id]);
|
||||
$shippingNoteId = WarehouseShippingNoteModel::create(['billingAddressId' => 3265, // Assuming a default billing address
|
||||
'deliveryAddressName' => $order->deliveryAddressName,
|
||||
'deliveryAddressLine' => $order->deliveryAddressLine,
|
||||
'deliveryAddressPLZ' => $order->deliveryAddressPLZ,
|
||||
'deliveryAddressCity' => $order->deliveryAddressCity,
|
||||
'deliveryAddressEMail' => '',
|
||||
'note' => 'Erstellung aus Shop Bestellung #' . $id,
|
||||
'status' => 'new',
|
||||
'positions' => $positions,
|
||||
'textElements' => '[]',
|
||||
'hoursEntries' => '[]',
|
||||
'eShopOrderId' => $id,
|
||||
'create' => time(),
|
||||
'createBy' => $this->user->id]);
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Lieferschein wurde erstellt', 'shippingNoteId' => $shippingNoteId]);
|
||||
|
||||
@@ -167,8 +186,9 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
$article = $item->articleId ? array_search($item->articleId, array_column($articles, 'id')) : null;
|
||||
$articlePacket = $item->articlePacketId ? array_search($item->articlePacketId, array_column($articlePackets, 'id')) : null;
|
||||
|
||||
$articleExtRef = $articleDistributor[array_search($item->articleId, array_column($articleDistributor, 'articleId'))];
|
||||
$articleExtRef = $item->articleId ? $articleExtRef->externalArticleNumber : (!empty($articlePacket->externalArticleNumber) ? $articlePacket->externalArticleNumber : null);
|
||||
$articleExtRef = $item->articleId && isset($articleDistributor[array_search($item->articleId, array_column($articleDistributor, 'articleId'))]) ? $articleDistributor[array_search($item->articleId, array_column($articleDistributor, 'articleId'))]->externalArticleNumber : null;
|
||||
$articleExtRef = $item->articlePacketId && isset($articlePackets[$articlePacket]) && !empty($articlePackets[$articlePacket]->externalArticleNumber) ? $articlePackets[$articlePacket]->externalArticleNumber : $articleExtRef;
|
||||
|
||||
$articleTitle = $item->articleId ? $articles[$article]->title : $articlePackets[$articlePacket]->title;
|
||||
$quantity = $item->quantity;
|
||||
$body .= $articleExtRef !== null ? "$quantity x $articleExtRef ($articleTitle)\n" : "$quantity x $articleTitle\n";
|
||||
@@ -183,7 +203,16 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
} else {
|
||||
$csvContent = $this->CSVExportNewOrdersMarkAcceptedAction(true, [$id]);
|
||||
|
||||
foreach (["ftth-versand@triotronik.com", "eshop-versand@xinon.at"] as $emailAddr) {
|
||||
// Determine recipient emails based on addressId
|
||||
$recipientEmails = ["eshop-versand@xinon.at"]; // Default for all orders
|
||||
if ($order->addressId === 209) {
|
||||
$recipientEmails[] = "ftth-versand@triotronik.com"; // Energie Steiermark specific
|
||||
} elseif ($order->addressId === 9633) {
|
||||
$recipientEmails[] = "sbidi-versand@xinon.at"; // SBIDI specific (example, adjust as needed)
|
||||
}
|
||||
|
||||
|
||||
foreach ($recipientEmails as $emailAddr) {
|
||||
$email = new Emailnotification();
|
||||
$email->setSubject("Bestellbestätigung Bestellung #$paddedId");
|
||||
$email->setBody($body);
|
||||
@@ -263,23 +292,32 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
return "$quantity x $articleExtRef ($articleTitle)";
|
||||
}, $orderItems));
|
||||
|
||||
$rows[] = ['AddressNumber' => '23000539',
|
||||
'Name' => $order['deliveryAddressName'],
|
||||
'Straße' => $order['deliveryAddressLine'],
|
||||
'Postleitzahl' => $order['deliveryAddressPLZ'],
|
||||
'Ort' => $order['deliveryAddressCity'],
|
||||
'Land' => 'AT',
|
||||
'Anschriftenzusatz 1' => $order['deliveryAddressAdditional'],
|
||||
'Produkte' => $orderItemsStr];
|
||||
// Determine AddressNumber based on order's addressId
|
||||
$addressNumber = '';
|
||||
if ($order['addressId'] === 209) {
|
||||
$addressNumber = '23000539'; // Energie Steiermark
|
||||
} elseif ($order['addressId'] === 9633) {
|
||||
$addressNumber = 'SBIDI_CUSTOMER_NUMBER'; // Placeholder for SBIDI, replace with actual
|
||||
}
|
||||
|
||||
|
||||
$rows[] = ['AddressNumber' => $addressNumber,
|
||||
'Name' => $order['deliveryAddressName'],
|
||||
'Straße' => $order['deliveryAddressLine'],
|
||||
'Postleitzahl' => $order['deliveryAddressPLZ'],
|
||||
'Ort' => $order['deliveryAddressCity'],
|
||||
'Land' => 'AT',
|
||||
'Anschriftenzusatz 1' => $order['deliveryAddressAdditional'],
|
||||
'Produkte' => $orderItemsStr];
|
||||
|
||||
WarehouseHistoryModel::create(['table' => 'WarehouseEShopOrder',
|
||||
'row_id' => $order['id'],
|
||||
'key' => 'status',
|
||||
'old_value' => 'new',
|
||||
'new_value' => 'accepted',
|
||||
'note' => 'CSV Export',
|
||||
'user_id' => $this->user->id,
|
||||
'create' => time()]);
|
||||
'row_id' => $order['id'],
|
||||
'key' => 'status',
|
||||
'old_value' => 'new',
|
||||
'new_value' => 'accepted',
|
||||
'note' => 'CSV Export',
|
||||
'user_id' => $this->user->id,
|
||||
'create' => time()]);
|
||||
|
||||
$order['status'] = 'accepted';
|
||||
WarehouseEShopOrderModel::update($order);
|
||||
@@ -310,15 +348,19 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
|
||||
$article = $item['articleId'] ? array_search($item['articleId'], array_column($articles, 'id')) : null;
|
||||
$articlePacket = $item['articlePacketId'] ? array_search($item['articlePacketId'], array_column($articlePackets, 'id')) : null;
|
||||
$articleExtRef = array_search($item['articleId'], array_column($articleDistributor, 'articleId'))['externalArticleNumber'] ?? null;
|
||||
$articleExtRef = null;
|
||||
if ($item['articleId'] && isset($articleDistributor[array_search($item['articleId'], array_column($articleDistributor, 'articleId'))])) {
|
||||
$articleExtRef = $articleDistributor[array_search($item['articleId'], array_column($articleDistributor, 'articleId'))]->externalArticleNumber;
|
||||
}
|
||||
|
||||
|
||||
$orderItems[$item['orderId']][] = ['id' => $item['id'],
|
||||
'articleId' => $item['articleId'],
|
||||
'articleExtRef' => $articleExtRef,
|
||||
'articleTitle' => isset($articles[$article]) ? $articles[$article]->title : null,
|
||||
'articlePacketId' => $item['articlePacketId'],
|
||||
'articlePacketTitle' => isset($articlePackets[$articlePacket]) ? $articlePackets[$articlePacket]->title : null,
|
||||
'quantity' => $item['quantity']];
|
||||
'articleId' => $item['articleId'],
|
||||
'articleExtRef' => $articleExtRef,
|
||||
'articleTitle' => isset($articles[$article]) ? $articles[$article]->title : null,
|
||||
'articlePacketId' => $item['articlePacketId'],
|
||||
'articlePacketTitle' => isset($articlePackets[$articlePacket]) ? $articlePackets[$articlePacket]->title : null,
|
||||
'quantity' => $item['quantity']];
|
||||
}
|
||||
|
||||
return $orderItems;
|
||||
@@ -339,19 +381,22 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
$json['status'] = 'new';
|
||||
$json['create'] = time();
|
||||
$json['createBy'] = $this->user->id;
|
||||
$json['addressId'] = $this->user->address_id; // Store the address_id of the ordering user
|
||||
|
||||
Helper::validateArray($json, $this->getCheckArray());
|
||||
|
||||
$id = WarehouseEShopOrderModel::create(['status' => 'new',
|
||||
'extRef' => $json['extRef'],
|
||||
'deliveryMode' => $json['deliveryMode'],
|
||||
'deliveryAddressAdditional' => $json['deliveryAddressAdditional'] ?? '',
|
||||
'deliveryAddressName' => $json['deliveryAddressName'],
|
||||
'deliveryAddressLine' => $json['deliveryAddressLine'],
|
||||
'deliveryAddressPLZ' => $json['deliveryAddressPLZ'],
|
||||
'deliveryAddressCity' => $json['deliveryAddressCity'],
|
||||
'create' => $json['create'],
|
||||
'createBy' => $json['createBy'],]);
|
||||
'extRef' => $json['extRef'],
|
||||
'deliveryMode' => $json['deliveryMode'],
|
||||
'deliveryAddressAdditional' => $json['deliveryAddressAdditional'] ?? '',
|
||||
'deliveryAddressName' => $json['deliveryAddressName'],
|
||||
'deliveryAddressLine' => $json['deliveryAddressLine'],
|
||||
'deliveryAddressPLZ' => $json['deliveryAddressPLZ'],
|
||||
'deliveryAddressCity' => $json['deliveryAddressCity'],
|
||||
'create' => $json['create'],
|
||||
'createBy' => $json['createBy'],
|
||||
'addressId' => $json['addressId'], // Pass the addressId
|
||||
]);
|
||||
|
||||
// now create WarehouseEShopOrderItems for each item in the shopping cart
|
||||
foreach ($shoppingCart as $item) {
|
||||
@@ -359,12 +404,12 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
// parse this and either fill articleId or articlePacketId for warehouseEShopOrderItem
|
||||
if (strpos($item['itemId'], 'P-') === 0) {
|
||||
WarehouseEShopOrderItemModel::create(['orderId' => $id,
|
||||
'articlePacketId' => intval(substr($item['itemId'], 2)),
|
||||
'quantity' => intval($item['amount']),]);
|
||||
'articlePacketId' => intval(substr($item['itemId'], 2)),
|
||||
'quantity' => intval($item['amount']),]);
|
||||
} else if (strpos($item['itemId'], 'I-') === 0) {
|
||||
WarehouseEShopOrderItemModel::create(['orderId' => $id,
|
||||
'articleId' => intval(substr($item['itemId'], 2)),
|
||||
'quantity' => intval($item['amount']),]);
|
||||
'articleId' => intval(substr($item['itemId'], 2)),
|
||||
'quantity' => intval($item['amount']),]);
|
||||
} else {
|
||||
self::returnJson(['success' => false, 'message' => 'Invalid item id']);
|
||||
die();
|
||||
@@ -390,8 +435,17 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
|
||||
$user = UserModel::getOne($json['createBy']);
|
||||
|
||||
if ($_SERVER['HTTP_HOST'] !== 'localhost')
|
||||
foreach (["office@xinon.at", $user->email] as $emailAddr) {
|
||||
if ($_SERVER['HTTP_HOST'] !== 'localhost') {
|
||||
$recipientEmails = ["office@xinon.at", $user->email];
|
||||
// Add shop-specific email if applicable
|
||||
if ($json['addressId'] === 209) {
|
||||
$recipientEmails[] = "ftth-versand@triotronik.com";
|
||||
} elseif ($json['addressId'] === 9633) {
|
||||
$recipientEmails[] = "sbidi-versand@xinon.at"; // Example for SBIDI
|
||||
}
|
||||
$recipientEmails = array_unique($recipientEmails); // Remove duplicates
|
||||
|
||||
foreach ($recipientEmails as $emailAddr) {
|
||||
$email = new Emailnotification();
|
||||
$email->setSubject("Bestellbestätigung Bestellung #$subjectId - Referenz: " . $json['extRef']);
|
||||
$email->setBody($body);
|
||||
@@ -399,10 +453,11 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
$email->setTo($emailAddr);
|
||||
$email->send();
|
||||
}
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true,
|
||||
'message' => $this->infoMessages['create'],
|
||||
'id' => $id]);
|
||||
'message' => $this->infoMessages['create'],
|
||||
'id' => $id]);
|
||||
}
|
||||
|
||||
protected function beforeCreate(): bool {
|
||||
@@ -551,8 +606,8 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
|
||||
|
||||
$orders = WarehouseEShopOrderModel::getAll(['deliveryAddressLine' => $addressLine,
|
||||
'deliveryAddressPLZ' => $plz,
|
||||
'deliveryAddressCity' => $city]);
|
||||
'deliveryAddressPLZ' => $plz,
|
||||
'deliveryAddressCity' => $city]);
|
||||
if (empty($orders)) {
|
||||
echo "No order found with address: $addressLine, $plz, $city" . PHP_EOL;
|
||||
continue;
|
||||
@@ -570,13 +625,13 @@ class WarehouseEShopOrderController extends TTCrud {
|
||||
WarehouseEShopOrderModel::update($order);
|
||||
|
||||
WarehouseHistoryModel::create(['table' => 'WarehouseEShopOrder',
|
||||
'row_id' => $order['id'],
|
||||
'key' => 'trackingNumber',
|
||||
'old_value' => '',
|
||||
'new_value' => $trackingNumber,
|
||||
'note' => '',
|
||||
'user_id' => 1,
|
||||
'create' => date('U')]);
|
||||
'row_id' => $order['id'],
|
||||
'key' => 'trackingNumber',
|
||||
'old_value' => '',
|
||||
'new_value' => $trackingNumber,
|
||||
'note' => '',
|
||||
'user_id' => 1,
|
||||
'create' => date('U')]);
|
||||
|
||||
// echo "Subject: " . $overview[0]->subject . "\n";
|
||||
// echo "From: " . $overview[0]->from . "\n";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* @property string $deliveryAddressCity
|
||||
* @property int $create
|
||||
* @property int $createBy
|
||||
* @property int $addressId // Added addressId property
|
||||
*/
|
||||
|
||||
class WarehouseEShopOrderModel extends TTCrudBaseModel {
|
||||
@@ -28,4 +29,5 @@ class WarehouseEShopOrderModel extends TTCrudBaseModel {
|
||||
public ?string $trackingNumber;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
public ?int $addressId; // New field to store the address_id of the ordering entity
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @property mixed|null $name
|
||||
*/
|
||||
class WarehouseEShopOrderItem extends mfBaseModel
|
||||
{
|
||||
|
||||
}
|
||||
@@ -53,11 +53,13 @@ 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['alternativePositions'] = json_encode([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($id): void
|
||||
protected function afterCreate($offer): void
|
||||
{
|
||||
$id = $offer['id'];
|
||||
$offer = WarehouseOfferModel::get($id);
|
||||
$this->createHistoryEntry($id, 1, $offer);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ final class AddDescriptionToAssetManagement extends AbstractMigration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
$table = $this->table('AssetManagement');
|
||||
$table->addColumn('description', 'text', [
|
||||
'null' => true,
|
||||
'after' => 'name',
|
||||
]);
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,8 +25,10 @@ final class AddDescriptionToAssetManagement extends AbstractMigration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
$table = $this->table('AssetManagement');
|
||||
$table->removeColumn('description');
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final class ModifyBorrowReasonInAssetManagementJournal extends AbstractMigration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
$table = $this->table('AssetManagementJournal');
|
||||
$table->changeColumn('borrowReason', 'text', [
|
||||
'null' => true,
|
||||
@@ -17,6 +18,7 @@ final class ModifyBorrowReasonInAssetManagementJournal extends AbstractMigration
|
||||
'after' => 'returnDate',
|
||||
]);
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,7 @@ final class ModifyBorrowReasonInAssetManagementJournal extends AbstractMigration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
// Reverting the changes made in the up() method.
|
||||
// This assumes the column was NOT NULL and had no comment previously.
|
||||
$table = $this->table('AssetManagementJournal');
|
||||
@@ -32,5 +35,6 @@ final class ModifyBorrowReasonInAssetManagementJournal extends AbstractMigration
|
||||
'comment' => 'Reason for borrowing the asset', // Set comment back to empty
|
||||
]);
|
||||
$table->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final class AssetManagementSchemaV2 extends AbstractMigration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
// 1. Add columns to AssetManagement table
|
||||
$assetManagement = $this->table('AssetManagement');
|
||||
$assetManagement
|
||||
@@ -91,6 +92,7 @@ final class AssetManagementSchemaV2 extends AbstractMigration
|
||||
])
|
||||
->addIndex(['assetId'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +100,7 @@ final class AssetManagementSchemaV2 extends AbstractMigration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
// Remove columns from AssetManagement
|
||||
$this->table('AssetManagement')
|
||||
->removeColumn('imageId')
|
||||
@@ -111,5 +114,6 @@ final class AssetManagementSchemaV2 extends AbstractMigration
|
||||
|
||||
// Drop reservation table
|
||||
$this->table('AssetManagementReservation')->drop()->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,150 +10,154 @@ final class WarehouseOfferVersioning extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Use Phinx schema builder to create the WarehouseOfferClosingText table
|
||||
$this->table('WarehouseOfferClosingText', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'engine' => 'InnoDB',
|
||||
'encoding' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'comment' => 'Stores standard closing text snippets for offers',
|
||||
])
|
||||
->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('name', 'string', ['limit' => 255])
|
||||
->addColumn('text', 'text')
|
||||
->addColumn('createBy', 'integer')
|
||||
->addColumn('create', 'integer')
|
||||
->create();
|
||||
|
||||
// Use Phinx schema builder to create the WarehouseOfferJournal table
|
||||
$this->table('WarehouseOfferJournal', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'engine' => 'InnoDB',
|
||||
'encoding' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'comment' => 'Journal for tracking actions on warehouse offers',
|
||||
])
|
||||
->addColumn('id', 'integer', ['identity' => true, 'signed' => false])
|
||||
->addColumn('offerId', 'integer', ['null' => true])
|
||||
->addColumn('fileIds', 'text', ['null' => true])
|
||||
->addColumn('message', 'string', ['limit' => 255, 'null' => true])
|
||||
->addColumn('create', 'integer', ['null' => true])
|
||||
->addColumn('createBy', 'integer', ['null' => true])
|
||||
->addIndex(['offerId'], ['name' => 'offerId'])
|
||||
->addIndex(['createBy'], ['name' => 'createBy'])
|
||||
->create();
|
||||
|
||||
// Use Phinx schema builder to add columns to the WarehouseOffer table
|
||||
$warehouseOffer = $this->table('WarehouseOffer');
|
||||
$warehouseOffer
|
||||
->addColumn('contactPersonEmail', 'string', ['limit' => 255, 'null' => true, 'after' => 'contactPerson'])
|
||||
->addColumn('lastSentDate', 'integer', ['null' => true, 'after' => 'status'])
|
||||
->addColumn('version', 'integer', ['default' => 1, 'after' => 'id'])
|
||||
->addColumn('history_id', 'integer', ['null' => true, 'after' => 'version'])
|
||||
->save();
|
||||
|
||||
// Use Phinx schema builder to add the 'data' column to WarehouseHistory
|
||||
$warehouseHistory = $this->table('WarehouseHistory');
|
||||
$warehouseHistory
|
||||
->addColumn('data', 'text', [
|
||||
'limit' => MysqlAdapter::TEXT_LONG,
|
||||
'null' => true,
|
||||
'after' => 'note',
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
// Use Phinx schema builder to create the WarehouseOfferClosingText table
|
||||
$this->table('WarehouseOfferClosingText', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'engine' => 'InnoDB',
|
||||
'encoding' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci'
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'comment' => 'Stores standard closing text snippets for offers',
|
||||
])
|
||||
->save();
|
||||
->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('name', 'string', ['limit' => 255])
|
||||
->addColumn('text', 'text')
|
||||
->addColumn('createBy', 'integer')
|
||||
->addColumn('create', 'integer')
|
||||
->create();
|
||||
|
||||
// Data migration steps remain as raw SQL execution due to their complexity.
|
||||
$this->execute("DELETE FROM `WarehouseHistory` WHERE `table` = 'WarehouseOffer';");
|
||||
// Use Phinx schema builder to create the WarehouseOfferJournal table
|
||||
$this->table('WarehouseOfferJournal', [
|
||||
'id' => false,
|
||||
'primary_key' => ['id'],
|
||||
'engine' => 'InnoDB',
|
||||
'encoding' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'comment' => 'Journal for tracking actions on warehouse offers',
|
||||
])
|
||||
->addColumn('id', 'integer', ['identity' => true, 'signed' => false])
|
||||
->addColumn('offerId', 'integer', ['null' => true])
|
||||
->addColumn('fileIds', 'text', ['null' => true])
|
||||
->addColumn('message', 'string', ['limit' => 255, 'null' => true])
|
||||
->addColumn('create', 'integer', ['null' => true])
|
||||
->addColumn('createBy', 'integer', ['null' => true])
|
||||
->addIndex(['offerId'], ['name' => 'offerId'])
|
||||
->addIndex(['createBy'], ['name' => 'createBy'])
|
||||
->create();
|
||||
|
||||
// Create a baseline version history for all existing offers.
|
||||
$this->execute("
|
||||
INSERT INTO `WarehouseHistory` (`table`, `row_id`, `key`, `old_value`, `new_value`, `note`, `data`, `user_id`, `create`)
|
||||
SELECT
|
||||
'WarehouseOffer' AS `table`,
|
||||
wo.id AS `row_id`,
|
||||
'version' AS `key`,
|
||||
0 AS `old_value`,
|
||||
1 AS `new_value`,
|
||||
'Baseline Version 1 erstellt durch Migration.' AS `note`,
|
||||
JSON_OBJECT(
|
||||
'id', wo.id,
|
||||
'version', 1,
|
||||
'history_id', NULL,
|
||||
'offerNumber', wo.offerNumber,
|
||||
'reference', wo.reference,
|
||||
'customerNumber', wo.customerNumber,
|
||||
'customerName', wo.customerName,
|
||||
'contactPerson', wo.contactPerson,
|
||||
'contactPersonEmail', wo.contactPersonEmail,
|
||||
'customerStreet', wo.customerStreet,
|
||||
'customerCity', wo.customerCity,
|
||||
'customerZip', wo.customerZip,
|
||||
'customerVAT', wo.customerVAT,
|
||||
'editor', wo.editor,
|
||||
'purpose', wo.purpose,
|
||||
'positions', wo.positions,
|
||||
'alternativePositions', wo.alternativePositions,
|
||||
'totalDiscount', wo.totalDiscount,
|
||||
'paymentTerms', wo.paymentTerms,
|
||||
'deliveryTerms', wo.deliveryTerms,
|
||||
'closingText', wo.closingText,
|
||||
'notes', wo.notes,
|
||||
'status', wo.status,
|
||||
'lastSentDate', wo.lastSentDate,
|
||||
'totalAmount', wo.totalAmount,
|
||||
'create', wo.create,
|
||||
'createBy', wo.createBy
|
||||
) AS `data`,
|
||||
wo.createBy AS `user_id`,
|
||||
UNIX_TIMESTAMP() AS `create`
|
||||
FROM `WarehouseOffer` wo;
|
||||
");
|
||||
// Use Phinx schema builder to add columns to the WarehouseOffer table
|
||||
$warehouseOffer = $this->table('WarehouseOffer');
|
||||
$warehouseOffer
|
||||
->addColumn('contactPersonEmail', 'string', ['limit' => 255, 'null' => true, 'after' => 'contactPerson'])
|
||||
->addColumn('lastSentDate', 'integer', ['null' => true, 'after' => 'status'])
|
||||
->addColumn('version', 'integer', ['default' => 1, 'after' => 'id'])
|
||||
->addColumn('history_id', 'integer', ['null' => true, 'after' => 'version'])
|
||||
->save();
|
||||
|
||||
// Update the history_id in the WarehouseOffer table to link to the newly created history entry.
|
||||
$this->execute("
|
||||
UPDATE `WarehouseOffer` wo
|
||||
JOIN `WarehouseHistory` wh ON wo.id = wh.row_id
|
||||
SET wo.history_id = wh.id
|
||||
WHERE wh.`table` = 'WarehouseOffer' AND wh.new_value = 1 AND wh.note = 'Baseline Version 1 erstellt durch Migration.';
|
||||
");
|
||||
// Use Phinx schema builder to add the 'data' column to WarehouseHistory
|
||||
$warehouseHistory = $this->table('WarehouseHistory');
|
||||
$warehouseHistory
|
||||
->addColumn('data', 'text', [
|
||||
'limit' => MysqlAdapter::TEXT_LONG,
|
||||
'null' => true,
|
||||
'after' => 'note',
|
||||
'encoding' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci'
|
||||
])
|
||||
->save();
|
||||
|
||||
// Data migration steps remain as raw SQL execution due to their complexity.
|
||||
$this->execute("DELETE FROM `WarehouseHistory` WHERE `table` = 'WarehouseOffer';");
|
||||
|
||||
// Create a baseline version history for all existing offers.
|
||||
$this->execute("
|
||||
INSERT INTO `WarehouseHistory` (`table`, `row_id`, `key`, `old_value`, `new_value`, `note`, `data`, `user_id`, `create`)
|
||||
SELECT
|
||||
'WarehouseOffer' AS `table`,
|
||||
wo.id AS `row_id`,
|
||||
'version' AS `key`,
|
||||
0 AS `old_value`,
|
||||
1 AS `new_value`,
|
||||
'Baseline Version 1 erstellt durch Migration.' AS `note`,
|
||||
JSON_OBJECT(
|
||||
'id', wo.id,
|
||||
'version', 1,
|
||||
'history_id', NULL,
|
||||
'offerNumber', wo.offerNumber,
|
||||
'reference', wo.reference,
|
||||
'customerNumber', wo.customerNumber,
|
||||
'customerName', wo.customerName,
|
||||
'contactPerson', wo.contactPerson,
|
||||
'contactPersonEmail', wo.contactPersonEmail,
|
||||
'customerStreet', wo.customerStreet,
|
||||
'customerCity', wo.customerCity,
|
||||
'customerZip', wo.customerZip,
|
||||
'customerVAT', wo.customerVAT,
|
||||
'editor', wo.editor,
|
||||
'purpose', wo.purpose,
|
||||
'positions', wo.positions,
|
||||
'alternativePositions', wo.alternativePositions,
|
||||
'totalDiscount', wo.totalDiscount,
|
||||
'paymentTerms', wo.paymentTerms,
|
||||
'deliveryTerms', wo.deliveryTerms,
|
||||
'closingText', wo.closingText,
|
||||
'notes', wo.notes,
|
||||
'status', wo.status,
|
||||
'lastSentDate', wo.lastSentDate,
|
||||
'totalAmount', wo.totalAmount,
|
||||
'create', wo.create,
|
||||
'createBy', wo.createBy
|
||||
) AS `data`,
|
||||
wo.createBy AS `user_id`,
|
||||
UNIX_TIMESTAMP() AS `create`
|
||||
FROM `WarehouseOffer` wo;
|
||||
");
|
||||
|
||||
// Update the history_id in the WarehouseOffer table to link to the newly created history entry.
|
||||
$this->execute("
|
||||
UPDATE `WarehouseOffer` wo
|
||||
JOIN `WarehouseHistory` wh ON wo.id = wh.row_id
|
||||
SET wo.history_id = wh.id
|
||||
WHERE wh.`table` = 'WarehouseOffer' AND wh.new_value = 1 AND wh.note = 'Baseline Version 1 erstellt durch Migration.';
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Clean up the history created by this migration
|
||||
$this->execute("DELETE FROM `WarehouseHistory` WHERE `note` = 'Baseline Version 1 erstellt durch Migration.' AND `table` = 'WarehouseOffer';");
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
// Clean up the history created by this migration
|
||||
$this->execute("DELETE FROM `WarehouseHistory` WHERE `note` = 'Baseline Version 1 erstellt durch Migration.' AND `table` = 'WarehouseOffer';");
|
||||
|
||||
// Revert changes to the WarehouseOffer table
|
||||
$warehouseOfferTable = $this->table('WarehouseOffer');
|
||||
if ($warehouseOfferTable->hasColumn('history_id')) {
|
||||
$warehouseOfferTable->removeColumn('history_id')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('version')) {
|
||||
$warehouseOfferTable->removeColumn('version')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('lastSentDate')) {
|
||||
$warehouseOfferTable->removeColumn('lastSentDate')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('contactPersonEmail')) {
|
||||
$warehouseOfferTable->removeColumn('contactPersonEmail')->save();
|
||||
}
|
||||
// Revert changes to the WarehouseOffer table
|
||||
$warehouseOfferTable = $this->table('WarehouseOffer');
|
||||
if ($warehouseOfferTable->hasColumn('history_id')) {
|
||||
$warehouseOfferTable->removeColumn('history_id')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('version')) {
|
||||
$warehouseOfferTable->removeColumn('version')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('lastSentDate')) {
|
||||
$warehouseOfferTable->removeColumn('lastSentDate')->save();
|
||||
}
|
||||
if ($warehouseOfferTable->hasColumn('contactPersonEmail')) {
|
||||
$warehouseOfferTable->removeColumn('contactPersonEmail')->save();
|
||||
}
|
||||
|
||||
// Revert changes to the WarehouseHistory table
|
||||
$warehouseHistoryTable = $this->table('WarehouseHistory');
|
||||
if ($warehouseHistoryTable->hasColumn('data')) {
|
||||
$warehouseHistoryTable->removeColumn('data')->save();
|
||||
}
|
||||
// Revert changes to the WarehouseHistory table
|
||||
$warehouseHistoryTable = $this->table('WarehouseHistory');
|
||||
if ($warehouseHistoryTable->hasColumn('data')) {
|
||||
$warehouseHistoryTable->removeColumn('data')->save();
|
||||
}
|
||||
|
||||
// Drop the newly created tables
|
||||
if ($this->hasTable('WarehouseOfferJournal')) {
|
||||
$this->table('WarehouseOfferJournal')->drop()->save();
|
||||
}
|
||||
if ($this->hasTable('WarehouseOfferClosingText')) {
|
||||
$this->table('WarehouseOfferClosingText')->drop()->save();
|
||||
// Drop the newly created tables
|
||||
if ($this->hasTable('WarehouseOfferJournal')) {
|
||||
$this->table('WarehouseOfferJournal')->drop()->save();
|
||||
}
|
||||
if ($this->hasTable('WarehouseOfferClosingText')) {
|
||||
$this->table('WarehouseOfferClosingText')->drop()->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class ShopAndOrderAddressEnhancements extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$eShopOrder = $this->table('WarehouseEShopOrder');
|
||||
$eShopOrder
|
||||
->addColumn('addressId', 'integer', [
|
||||
'null' => true,
|
||||
'after' => 'createBy'
|
||||
])
|
||||
->addIndex(['addressId'], ['name' => 'idx_addressId'])
|
||||
->save();
|
||||
|
||||
$this->execute("UPDATE `WarehouseEShopOrder` SET `addressId` = 209 WHERE `addressId` IS NULL");
|
||||
|
||||
$article = $this->table('WarehouseArticle');
|
||||
$article
|
||||
->addColumn('isSbidiShop', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'isEShop'
|
||||
])
|
||||
->addColumn('isSbidiShopHide', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'isSbidiShop'
|
||||
])
|
||||
->addIndex(['isEShop'], ['name' => 'idx_isEShop'])
|
||||
->addIndex(['isEShopHide'], ['name' => 'idx_isEShopHide'])
|
||||
->addIndex(['isSbidiShop'], ['name' => 'idx_isSbidiShop'])
|
||||
->addIndex(['isSbidiShopHide'], ['name' => 'idx_isSbidiShopHide'])
|
||||
->save();
|
||||
|
||||
$this->execute("UPDATE `WarehouseArticle` SET `isSbidiShop` = 0, `isSbidiShopHide` = 0 WHERE `isSbidiShop` IS NULL");
|
||||
|
||||
$articlePacket = $this->table('WarehouseArticlePacket');
|
||||
$articlePacket
|
||||
->addColumn('isEShop', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'calculatedSellPrice'
|
||||
])
|
||||
->addColumn('isEShopHide', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'isEShop'
|
||||
])
|
||||
->addColumn('isSbidiShop', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'isEShopHide'
|
||||
])
|
||||
->addColumn('isSbidiShopHide', 'integer', [
|
||||
'default' => 0,
|
||||
'after' => 'isSbidiShop'
|
||||
])
|
||||
->addIndex(['isEShop'], ['name' => 'idx_isEShop_packet'])
|
||||
->addIndex(['isEShopHide'], ['name' => 'idx_isEShopHide_packet'])
|
||||
->addIndex(['isSbidiShop'], ['name' => 'idx_isSbidiShop_packet'])
|
||||
->addIndex(['isSbidiShopHide'], ['name' => 'idx_isSbidiShopHide_packet'])
|
||||
->save();
|
||||
|
||||
$this->execute("
|
||||
UPDATE `WarehouseArticlePacket`
|
||||
SET `isEShop` = 1, `isEShopHide` = 0, `isSbidiShop` = 0, `isSbidiShopHide` = 0
|
||||
WHERE `isEShop` IS NULL
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$articlePacket = $this->table('WarehouseArticlePacket');
|
||||
$articlePacket
|
||||
->removeColumn('isSbidiShopHide')
|
||||
->removeColumn('isSbidiShop')
|
||||
->removeColumn('isEShopHide')
|
||||
->removeColumn('isEShop')
|
||||
->save();
|
||||
|
||||
$article = $this->table('WarehouseArticle');
|
||||
$article
|
||||
->removeColumn('isSbidiShopHide')
|
||||
->removeColumn('isSbidiShop')
|
||||
->save();
|
||||
|
||||
$article
|
||||
->removeIndexByName('idx_isEShop')
|
||||
->removeIndexByName('idx_isEShopHide')
|
||||
->removeIndexByName('idx_isSbidiShop')
|
||||
->removeIndexByName('idx_isSbidiShopHide')
|
||||
->save();
|
||||
|
||||
$eShopOrder = $this->table('WarehouseEShopOrder');
|
||||
$eShopOrder
|
||||
->removeColumn('addressId')
|
||||
->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
52
db/migrations/20250721222300_cpeprov_add_new_indexes.php
Normal file
52
db/migrations/20250721222300_cpeprov_add_new_indexes.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CpeprovAddNewIndexes extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->getEnvironment() == 'thetool') {
|
||||
$orderProduct = $this->table('OrderProduct');
|
||||
$orderProduct->addIndex('product_id', ['name' => 'idx_product_id'])
|
||||
->addIndex('termination_id', ['name' => 'idx_termination_id'])
|
||||
->save();
|
||||
|
||||
$product = $this->table('Product');
|
||||
$product->addIndex('producttech_id', ['name' => 'idx_producttech_id'])
|
||||
->save();
|
||||
|
||||
$productAttribute = $this->table('ProductAttribute');
|
||||
$productAttribute->addIndex('producttechattribute_id', ['name' => 'idx_producttechattribute_id'])
|
||||
->save();
|
||||
|
||||
$termination = $this->table('Termination');
|
||||
$termination->addIndex('status_id', ['name' => 'idx_status_id'])
|
||||
->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->getEnvironment() == 'thetool') {
|
||||
$orderProduct = $this->table('OrderProduct');
|
||||
$orderProduct->removeIndexByName('idx_product_id')
|
||||
->removeIndexByName('idx_termination_id')
|
||||
->save();
|
||||
|
||||
$product = $this->table('Product');
|
||||
$product->removeIndexByName('idx_producttech_id')
|
||||
->save();
|
||||
|
||||
$productAttribute = $this->table('ProductAttribute');
|
||||
$productAttribute->removeIndexByName('idx_producttechattribute_id')
|
||||
->save();
|
||||
|
||||
$termination = $this->table('Termination');
|
||||
$termination->removeIndexByName('idx_status_id')
|
||||
->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
59
db/migrations/20250723204000_CreateRmlWorkorderTables.php
Normal file
59
db/migrations/20250723204000_CreateRmlWorkorderTables.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateRmlWorkorderTables extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$this->execute('DROP TABLE IF EXISTS `RMLWorkorderDocumentation`');
|
||||
$this->execute('DROP TABLE IF EXISTS `RMLWorkorderCompany`');
|
||||
$this->execute('DROP TABLE IF EXISTS `RMLWorkorder`');
|
||||
|
||||
$workorder = $this->table('RMLWorkorder', ['id' => false, 'primary_key' => ['id']]);
|
||||
$workorder->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('preorderId', 'integer', ['null' => false])
|
||||
->addColumn('companyId', 'integer', ['null' => true])
|
||||
->addColumn('status', 'string', ['limit' => 50, 'null' => false, 'default' => 'new'])
|
||||
->addColumn('assignmentDate', 'integer', ['null' => true])
|
||||
->addColumn('deadlineDate', 'integer', ['null' => true])
|
||||
->addColumn('appointmentDate', 'integer', ['null' => true])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->addIndex(['preorderId'], ['name' => 'preorderId_idx'])
|
||||
->addIndex(['companyId'], ['name' => 'companyId_idx'])
|
||||
->addIndex(['status'], ['name' => 'status_idx'])
|
||||
->create();
|
||||
|
||||
$company = $this->table('RMLWorkorderCompany', ['id' => false, 'primary_key' => ['id']]);
|
||||
$company->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('addressId', 'integer', ['null' => false])
|
||||
->addColumn('name', 'string', ['limit' => 255, 'null' => false])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->create();
|
||||
|
||||
$documentation = $this->table('RMLWorkorderDocumentation', ['id' => false, 'primary_key' => ['id']]);
|
||||
$documentation->addColumn('id', 'integer', ['identity' => true, 'signed' => true])
|
||||
->addColumn('workorderId', 'integer', ['null' => false])
|
||||
->addColumn('fileId', 'integer', ['null' => false])
|
||||
->addColumn('description', 'text', ['null' => true])
|
||||
->addColumn('documentType', 'string', ['limit' => 100, 'null' => false])
|
||||
->addColumn('create', 'integer', ['null' => false])
|
||||
->addColumn('createBy', 'integer', ['null' => false])
|
||||
->addIndex(['workorderId'], ['name' => 'workorderId_idx'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$this->table('RMLWorkorderDocumentation')->drop()->save();
|
||||
$this->table('RMLWorkorderCompany')->drop()->save();
|
||||
$this->table('RMLWorkorder')->drop()->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,14 @@ RUN echo "* * * * * /root/clean_old_logs.sh" > /etc/cron.d/clean_old_logs && \
|
||||
|
||||
# Start Apache in the foreground
|
||||
CMD ["apachectl", "-D", "FOREGROUND"]
|
||||
|
||||
|
||||
# Install XDEBUG
|
||||
# apt install -y php8.2-xdebug
|
||||
#
|
||||
# cat <<'EOF' > /etc/php/8.2/apache2/conf.d/99-xdebug-custom.ini
|
||||
#[xdebug]
|
||||
#xdebug.mode=profile
|
||||
#xdebug.start_with_request=trigger
|
||||
#xdebug.output_dir="/tmp/xdebug_profiles"
|
||||
#EOF
|
||||
@@ -6,4 +6,24 @@
|
||||
<Directory /var/www/html>
|
||||
AllowOverride All
|
||||
</Directory>
|
||||
|
||||
<IfModule mod_deflate.c>
|
||||
# Enable compression
|
||||
SetOutputFilter DEFLATE
|
||||
|
||||
# Set compression level (1-9, 9 is highest)
|
||||
DeflateCompressionLevel 6
|
||||
|
||||
# Add compression for specific MIME types
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
AddOutputFilterByType DEFLATE text/xml
|
||||
AddOutputFilterByType DEFLATE text/css
|
||||
AddOutputFilterByType DEFLATE application/xml
|
||||
AddOutputFilterByType DEFLATE application/xhtml+xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml
|
||||
AddOutputFilterByType DEFLATE application/javascript
|
||||
AddOutputFilterByType DEFLATE application/json
|
||||
AddOutputFilterByType DEFLATE image/svg+xml
|
||||
</IfModule>
|
||||
</VirtualHost>
|
||||
|
||||
@@ -49,6 +49,7 @@ class TTCrud extends mfBaseController {
|
||||
$this->postData = json_decode(file_get_contents('php://input'), true);
|
||||
$this->checkArray = $this->getCheckArray();
|
||||
$this->infoMessages = $this->getInfoMessages();
|
||||
if (method_exists($this, 'afterInit')) $this->afterInit();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -393,7 +393,7 @@ class mfBaseController
|
||||
|
||||
public static function sendError(string $message): void {
|
||||
http_response_code(500);
|
||||
self::returnJson(['success' => false, 'message' => $message]);
|
||||
self::returnJson(['success' => false, 'message' => $message, 'error' => $message]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ $jsFiles = [
|
||||
"plugins/vue/tt-components/tt-position-manager.js",
|
||||
"plugins/vue/tt-components/tt-tooltip.js",
|
||||
"plugins/vue/tt-components/tt-map.js",
|
||||
"plugins/vue/tt-components/tt-file-gallery.js",
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
define('mfUI',"web");
|
||||
|
||||
if(file_exists("../config/config.php")) {
|
||||
require("../config/config.php");
|
||||
require("../config/config.php");
|
||||
} else {
|
||||
die("CANNOT FIND CONFIGFILE!\n\nThis is a serious error. You should not run your mvcfronk application without a configfile. Not proceeding.");
|
||||
die("CANNOT FIND CONFIGFILE!\n\nThis is a serious error. You should not run your mvcfronk application without a configfile. Not proceeding.");
|
||||
}
|
||||
|
||||
if(defined('MFLOCALE_TIME')) {
|
||||
setlocale(LC_TIME, MFLOCALE_TIME);
|
||||
setlocale(LC_TIME, MFLOCALE_TIME);
|
||||
}
|
||||
if(defined('MFLOCALE_MONETARY')) {
|
||||
setlocale(LC_MONETARY, MFLOCALE_MONETARY);
|
||||
setlocale(LC_MONETARY, MFLOCALE_MONETARY);
|
||||
}
|
||||
/* disabled because of issues with saving float values to mysql
|
||||
if(defined('MFLOCALE_NUMERIC')) {
|
||||
@@ -36,14 +36,14 @@ $app=new mfRouter($request);
|
||||
|
||||
|
||||
if(defined("MFVALUECACHE_DEBUG") && MFVALUECACHE_DEBUG) {
|
||||
$i = 0;
|
||||
$cache = mfValuecache::singleton()->getCache();
|
||||
echo "<pre class='xdebug-var-dump'>\n";
|
||||
echo "<small>mfValuecache keys total: ".count($cache)."</small>\n";
|
||||
foreach($cache as $key => $value) {
|
||||
echo "\t$i => $key (". gettype($value).")\n";
|
||||
$i++;
|
||||
}
|
||||
$i = 0;
|
||||
$cache = mfValuecache::singleton()->getCache();
|
||||
echo "<pre class='xdebug-var-dump'>\n";
|
||||
echo "<small>mfValuecache keys total: ".count($cache)."</small>\n";
|
||||
foreach($cache as $key => $value) {
|
||||
echo "\t$i => $key (". gettype($value).")\n";
|
||||
$i++;
|
||||
}
|
||||
|
||||
echo "</pre>";
|
||||
echo "</pre>";
|
||||
}
|
||||
|
||||
159
public/js/pages/Cpeprovisioning/Cpeprovisioning.css
Normal file
159
public/js/pages/Cpeprovisioning/Cpeprovisioning.css
Normal file
@@ -0,0 +1,159 @@
|
||||
/* Cpeprovisioning.css */
|
||||
|
||||
.cpe-provisioning-page .filter-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1.5rem; /* Align with form labels */
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .cpe-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .section-title {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #005384;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #f7c423;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .info-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .info-pill {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.8rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .info-pill i {
|
||||
color: #005384;
|
||||
}
|
||||
|
||||
/* Ensure form groups don't have excessive bottom margin in the grid */
|
||||
.cpe-provisioning-page .cpe-details-grid .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Save button alignment */
|
||||
.cpe-provisioning-page .save-button-container {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* For tt-table expanded row content */
|
||||
.tt-table tbody tr[style*="display: table-row;"] > td {
|
||||
background-color: #f8f9fa !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Change tracking */
|
||||
.cpe-provisioning-page .is-dirty {
|
||||
background-color: #fff3cd; /* A light yellow to indicate changes */
|
||||
border-color: #ffeeba;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .form-group-condensed .col-form-label {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.cpe-provisioning-page .filter-grid,
|
||||
.cpe-provisioning-page .cpe-details-grid {
|
||||
grid-template-columns: 1fr; /* Stack on smaller screens */
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .filter-actions {
|
||||
padding-top: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cpe-provisioning-page .filter-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.vlans-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/*center items in the middle of the full width*/
|
||||
justify-content: center;
|
||||
/*flex-direction: column;*/
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* TODO: MOVE TT-CHIP TO OWN FILE */
|
||||
.tt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
border-radius: 16px; /* Pill shape */
|
||||
background-color: #f1f3f5; /* Light grey for unchecked state */
|
||||
border: 1px solid #dee2e6;
|
||||
font-size: 0.875em; /* 14px if base is 16px */
|
||||
margin: 3px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.tt-chip.is-checked {
|
||||
background-color: #e7f5ff; /* A pleasant light blue for the checked state */
|
||||
border-color: #a5d8ff;
|
||||
color: #1c7ed6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Add a subtle shadow on hover for interactivity */
|
||||
.tt-chip:hover {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Style elements passed into the slot for consistent spacing and alignment */
|
||||
.tt-chip > * + * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tt-chip input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
238
public/js/pages/Cpeprovisioning/Cpeprovisioning.js
Normal file
238
public/js/pages/Cpeprovisioning/Cpeprovisioning.js
Normal file
@@ -0,0 +1,238 @@
|
||||
// Cpeprovisioning.js
|
||||
Vue.component('tt-chip', {
|
||||
props: {
|
||||
checked: { type: Boolean, default: false }
|
||||
},
|
||||
template: `<div class="tt-chip" :class="{ 'is-checked': checked }"><slot></slot></div>`
|
||||
});
|
||||
|
||||
Vue.component('Cpeprovisioning', {
|
||||
template: `
|
||||
<div class="cpe-provisioning-page">
|
||||
<tt-card>
|
||||
<div class="filter-card">
|
||||
<div class="filter-grid">
|
||||
<tt-select label="Netzgebiet" :options="networkOptions" v-model="filters.network_id" sm/>
|
||||
<tt-select label="Provisioningstatus" :options="statusOptions" v-model="filters.routerconfig_finished" sm/>
|
||||
<tt-select label="Verzögerte Herstellung" :options="delayOptions" v-model="filters.hide_delayed_finish" sm/>
|
||||
<tt-input label="Kunde" v-model="filters.owner" sm placeholder="Name, SPIN, ..."/>
|
||||
<div class="filter-actions">
|
||||
<tt-button text="Anwenden" @click="applyFilters" additional-class="btn-primary" sm/>
|
||||
<tt-button text="Zurücksetzen" @click="resetFilters" additional-class="btn-secondary" sm/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</tt-card>
|
||||
|
||||
<tt-card :no-body-padding-top="true">
|
||||
<tt-table
|
||||
:fetch-url="window.TT_CONFIG.CPE_PROV_API_GET_URL"
|
||||
:config="tableConfig"
|
||||
ref="cpeTable"
|
||||
key="cpeProvisioningTable"
|
||||
ssr>
|
||||
|
||||
<template v-slot:customer="{ row }">
|
||||
<div><strong>{{ row.customer }}</strong></div>
|
||||
<div><small class="text-muted">SPIN: {{ row.spin }}</small></div>
|
||||
<div><small>{{ row.network }}</small></div>
|
||||
</template>
|
||||
|
||||
<template v-slot:product="{ row }">
|
||||
<div><strong>{{ row.product_name }}</strong></div>
|
||||
<div><small class="text-muted">{{ row.product_code }}</small></div>
|
||||
<div>
|
||||
<small>{{ row.access_type }}</small>
|
||||
<small><i class="fas fa-arrow-down"></i> {{ row.access_type_down }} Mbit/s</small>
|
||||
<small><i class="fas fa-arrow-up"></i> {{ row.access_type_up }} Mbit/s</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 d-flex align-items-center">
|
||||
<a target="_blank" :href="window.TT_CONFIG.ORDER_URL + '/Index/?id=' + row.order_id + '&addJournal=1'" class="mr-2">
|
||||
<i class="fas fa-scroll"></i>
|
||||
</a>
|
||||
<a target="_blank" :href="window.TT_CONFIG.CPE_PROV_PRINT_PDF_URL + '?order_id=' + row.order_id" class="mr-2">
|
||||
<i class="fas fa-print"></i>
|
||||
</a>
|
||||
<template v-if="row.vot || row.hw || row.voip || row.note">
|
||||
<tt-tooltip v-if="row.vot" text="Vorortinstallation" position="top">
|
||||
<i class="fas fa-tools text-purple mr-2"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.hw" :text="row.hw" position="top" class="mr-2">
|
||||
<i class="fas fa-shopping-bag text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.voip" text="Voice Produkt vorhanden" position="top" class="mr-2">
|
||||
<i class="fas fa-phone text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<tt-tooltip v-if="row.note" :text="row.note" position="top" allow-wrapping class="mr-2">
|
||||
<i class="fas fa-clipboard-list text-purple"></i>
|
||||
</tt-tooltip>
|
||||
<a target="_blank" :href="row.snopp_url" class="mr-2">
|
||||
<tt-tooltip v-if="row.show_snopp_button" text="SNOPP" position="top" allow-wrapping>
|
||||
<img style="height: 18px; vertical-align: middle;" class="logo-top-search" src="/img/snop-logo.png" alt="Snop Logo">
|
||||
</tt-tooltip>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:vlans="{ row }">
|
||||
<div class="vlans-container">
|
||||
<template v-for="(vlan, key) in row.vlans">
|
||||
<tt-chip v-if="vlan.tag" class="vlan-chip" :key="key">
|
||||
<input type="checkbox" :checked="vlan.checked" @change="markDirty(row, 1); vlan.checked = !vlan.checked"/>
|
||||
<span>{{ key.charAt(0).toUpperCase() + key.slice(1) }}: {{ vlan.tag }}</span>
|
||||
</tt-chip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div class="cpe-details-grid">
|
||||
<h5 class="section-title">Router Konfiguration</h5>
|
||||
<tt-select label="Router" :options="routerOptions" v-model="row.cpe_data.routertype" @input="markDirty(row, 2)" sm/>
|
||||
<tt-input label="WLAN SSID" v-model="row.cpe_data.wifi_ssid" @input="markDirty(row, 3)" sm/>
|
||||
<tt-input label="WPA Key" v-model="row.cpe_data.wifi_pass" @input="markDirty(row, 4)" sm/>
|
||||
<tt-input label="Router MAC" v-model="row.cpe_data.mac" @input="markDirty(row, 5)" sm/>
|
||||
<tt-input v-if="row.termination_id" label="ONT SN" v-model="row.ont_sn" @input="markDirty(row, 6)" sm/>
|
||||
|
||||
<h5 class="section-title">Versand</h5>
|
||||
<tt-checkbox label="Versandauftrag" v-model="row.cpe_data.shipping" @input="markDirty(row, 7);checkShipping(row)" sm/>
|
||||
<tt-input label="Gewicht (kg)" v-model="row.cpe_data.ship_weight" @input="markDirty(row, 8)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Länge (cm)" v-model="row.cpe_data.ship_length" @input="markDirty(row, 9)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Breite (cm)" v-model="row.cpe_data.ship_width" @input="markDirty(row, 10)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
<tt-input label="Höhe (cm)" v-model="row.cpe_data.ship_height" @input="markDirty(row, 11)" sm type="number" :disabled="!row.cpe_data.shipping"/>
|
||||
|
||||
<h5 class="section-title">Abschluss</h5>
|
||||
<div style="grid-column: 1 / -1;">
|
||||
<tt-textarea label="Kommentar" v-model="row.cpe_data.note" @input="markDirty(row, 12)" sm/>
|
||||
</div>
|
||||
<tt-checkbox label="Konfig abgeschlossen" v-model="row.cpe_data.routerconfig_finished" @input="markDirty(row, 13)" sm/>
|
||||
|
||||
<div class="save-button-container">
|
||||
<tt-button
|
||||
text="Speichern"
|
||||
@click="saveCpe(row)"
|
||||
:loading="row.isSaving"
|
||||
:disabled="!row.isDirty"
|
||||
:additional-class="row.isDirty ? 'btn-success' : 'btn-secondary'"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tt-table>
|
||||
</tt-card>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
window,
|
||||
filters: {
|
||||
network_id: '',
|
||||
routerconfig_finished: '0',
|
||||
hide_delayed_finish: '1',
|
||||
owner: ''
|
||||
},
|
||||
statusOptions: [
|
||||
{ value: '0', text: 'Offen' },
|
||||
{ value: '1', text: 'Abgeschlossen' }
|
||||
],
|
||||
delayOptions: [
|
||||
{ value: '1', text: 'Nicht anzeigen' },
|
||||
{ value: '0', text: 'Anzeigen' }
|
||||
],
|
||||
tableConfig: {
|
||||
key: 'cpeProvisioning',
|
||||
tableHeader: 'CPE Provisioning',
|
||||
expandCondition: () => true,
|
||||
customRowClass: row => (row.isDirty ? 'is-dirty' : ''),
|
||||
headers: [
|
||||
{ key: 'customer', text: 'Kunde', sortable: false, filter: false, priority: 100 },
|
||||
{ key: 'product', text: 'Produkt', sortable: false, filter: false, priority: 90 },
|
||||
{ key: 'vlans', text: 'VLANs', sortable: false, filter: false, priority: 80 },
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
networkOptions() {
|
||||
const networks = window.TT_CONFIG.NETWORKS || [];
|
||||
return [{ value: '', text: 'Alle Gebiete' }, ...networks.map(net => ({ value: net.id, text: net.name }))];
|
||||
},
|
||||
routerOptions() {
|
||||
return window.TT_CONFIG.ROUTER_OPTIONS || [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
applyFilters(fetch = true) {
|
||||
const table = this.$refs.cpeTable;
|
||||
if (table) {
|
||||
table.filters = JSON.parse(JSON.stringify(this.filters));
|
||||
if (fetch) table.fetchData(1);
|
||||
}
|
||||
},
|
||||
resetFilters() {
|
||||
this.filters = {
|
||||
network_id: '',
|
||||
routerconfig_finished: '0',
|
||||
hide_delayed_finish: '1',
|
||||
owner: ''
|
||||
};
|
||||
this.applyFilters();
|
||||
},
|
||||
markDirty(row, field) {
|
||||
console.log(`Marking row as dirty for field ${field}`);
|
||||
this.$set(row, 'isDirty', true);
|
||||
},
|
||||
async checkShipping (row) {
|
||||
await this.$nextTick();
|
||||
if (row.cpe_data.shipping && row.cpe_data.routertype) {
|
||||
const shippingData = this.window.TT_CONFIG.ROUTER_SHIPPING_DATA[row.cpe_data.routertype];
|
||||
if (shippingData) {
|
||||
if (!row.cpe_data.ship_weight) row.cpe_data.ship_weight = shippingData.weight;
|
||||
if (!row.cpe_data.ship_length) row.cpe_data.ship_length = shippingData.length;
|
||||
if (!row.cpe_data.ship_width) row.cpe_data.ship_width = shippingData.width;
|
||||
if (!row.cpe_data.ship_height) row.cpe_data.ship_height = shippingData.height;
|
||||
}
|
||||
} else {
|
||||
row.cpe_data.ship_weight = '';
|
||||
row.cpe_data.ship_length = '';
|
||||
row.cpe_data.ship_width = '';
|
||||
row.cpe_data.ship_height = '';
|
||||
}
|
||||
|
||||
},
|
||||
async saveCpe(row) {
|
||||
this.$set(row, 'isSaving', true);
|
||||
|
||||
const payload = {
|
||||
id: row.cpe_id,
|
||||
order_id: row.order_id,
|
||||
orderproduct_id: row.orderproduct_id,
|
||||
termination_id: row.termination_id,
|
||||
ont_sn: row.ont_sn,
|
||||
vlans: row.vlans,
|
||||
...row.cpe_data,
|
||||
shipping: row.cpe_data.shipping ? 1 : 0,
|
||||
routerconfig_finished: row.cpe_data.routerconfig_finished ? 1 : 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(this.window.TT_CONFIG.CPE_PROV_API_SAVE_URL, payload);
|
||||
if (data.success) {
|
||||
this.window.notify('success', data.message);
|
||||
this.$set(row, 'isDirty', false);
|
||||
this.$refs.cpeTable.refreshTable();
|
||||
} else {
|
||||
this.window.notify('error', data.message || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (error) {
|
||||
this.window.notify('error', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
this.$set(row, 'isSaving', false);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.applyFilters(false);
|
||||
}
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
// RMLWorkorder.js
|
||||
|
||||
// =================================================================================
|
||||
// Main Component - Switches between Admin and Company View
|
||||
// =================================================================================
|
||||
Vue.component('r-m-l-workorder', {
|
||||
template: `
|
||||
<div>
|
||||
<rml-workorder-admin-view v-if="window.TT_CONFIG.RML_ADMIN === '1'"></rml-workorder-admin-view>
|
||||
<rml-workorder-company-view v-else></rml-workorder-company-view>
|
||||
</div>
|
||||
`,
|
||||
data() { return { window: window } }
|
||||
});
|
||||
|
||||
|
||||
// =================================================================================
|
||||
// RML Admin View
|
||||
// =================================================================================
|
||||
Vue.component('rml-workorder-admin-view', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<assign-company-modal
|
||||
v-if="assignModalWorkorderId"
|
||||
:workorder-id="assignModalWorkorderId"
|
||||
@close="assignModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
|
||||
<documentation-viewer-modal
|
||||
v-if="docsModalWorkorderId"
|
||||
:workorder-id="docsModalWorkorderId"
|
||||
@close="docsModalWorkorderId = null"
|
||||
/>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
@assign="assignModalWorkorderId = $event.id"
|
||||
@view_docs="docsModalWorkorderId = $event.id"
|
||||
:crud-config="crudConfig"
|
||||
>
|
||||
|
||||
<!-- <template v-slot:preorderinfo="{ row }">-->
|
||||
<!-- <div v-html="row.preorderInfo"></div>-->
|
||||
<!-- </template>-->
|
||||
|
||||
<!-- <template v-slot:status="{ row }">-->
|
||||
<!-- <traffic-light :deadline="row.deadlineDate" :status="row.status" />-->
|
||||
<!-- <i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>-->
|
||||
<!-- <span class="ml-2">{{ getStatusColumn(row.status).text }}</span>-->
|
||||
<!-- </template>-->
|
||||
|
||||
<!-- <template v-slot:deadlinedate="{ row }">-->
|
||||
<!-- {{ formatDate(row.deadlineDate) }}-->
|
||||
<!-- </template>-->
|
||||
<!-- -->
|
||||
<!-- <template v-slot:appointmentdate="{ row }">-->
|
||||
<!-- {{ formatDate(row.appointmentDate) }}-->
|
||||
<!-- </template>-->
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
assignModalWorkorderId: null,
|
||||
docsModalWorkorderId: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
additionalActions: [
|
||||
{
|
||||
"key": "assign",
|
||||
"title": "Firma zuweisen",
|
||||
"class": "fas fa-user-plus text-primary",
|
||||
"condition": (row) => row.status === 'new',
|
||||
},
|
||||
{
|
||||
"key": "view_docs",
|
||||
"title": "Dokumentation ansehen",
|
||||
"class": "fas fa-folder-open text-info",
|
||||
"condition": (row) => ['documented', 'completed'].includes(row.status),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// =================================================================================
|
||||
// RML Company View
|
||||
// =================================================================================
|
||||
Vue.component('rml-workorder-company-view', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<schedule-appointment-modal
|
||||
v-if="scheduleModalWorkorderId"
|
||||
:workorder-id="scheduleModalWorkorderId"
|
||||
@close="scheduleModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
<documentation-modal
|
||||
v-if="documentModalWorkorder"
|
||||
:workorder="documentModalWorkorder"
|
||||
@close="documentModalWorkorder = null; $refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
@schedule="scheduleModalWorkorderId = $event.id"
|
||||
@document="documentModalWorkorder = $event"
|
||||
:crud-config="crudConfig"
|
||||
>
|
||||
<template v-slot:preorderinfo="{ row }">
|
||||
<div v-html="row.preorderInfo"></div>
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
||||
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
||||
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:deadlinedate="{ row }">
|
||||
{{ formatDate(row.deadlineDate) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:appointmentdate="{ row }">
|
||||
{{ formatDate(row.appointmentDate) }}
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
scheduleModalWorkorderId: null,
|
||||
documentModalWorkorder: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
additionalActions: [
|
||||
{
|
||||
"key": "schedule",
|
||||
"title": "Termin festlegen",
|
||||
"class": "fas fa-calendar-plus text-primary",
|
||||
"condition": (row) => row.status === 'assigned',
|
||||
},
|
||||
{
|
||||
"key": "document",
|
||||
"title": "Dokumentieren & Abschließen",
|
||||
"class": "fas fa-camera text-success",
|
||||
"condition": (row) => ['assigned', 'scheduled', 'documented'].includes(row.status),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =================================================================================
|
||||
// Modals and Helper Components
|
||||
// =================================================================================
|
||||
|
||||
// Traffic Light Component
|
||||
Vue.component('traffic-light', {
|
||||
props: ['deadline', 'status'],
|
||||
computed: {
|
||||
lightColor() {
|
||||
if (this.status === 'completed') return '#cccccc'; // Grey for completed
|
||||
const now = moment();
|
||||
const deadlineDate = moment.unix(this.deadline);
|
||||
if (!deadlineDate.isValid()) return '#cccccc'; // Grey for invalid date
|
||||
|
||||
if (deadlineDate.isBefore(now)) return '#dc3545'; // Red for overdue
|
||||
if (deadlineDate.isBefore(now.clone().add(1, 'weeks'))) return '#dc3545'; // Red
|
||||
if (deadlineDate.isBefore(now.clone().add(3, 'weeks'))) return '#ffc107'; // Yellow
|
||||
return '#28a745'; // Green
|
||||
}
|
||||
},
|
||||
template: `<span :style="{ color: lightColor, fontSize: '1.2em' }" class="mr-2" title="Dringlichkeit">●</span>`
|
||||
});
|
||||
|
||||
// Modal for RML Admin to assign a company
|
||||
Vue.component('assign-company-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<tt-modal :show="true" title="Firma zuweisen" @submit="submit" @update:show="$emit('close')">
|
||||
<tt-select label="Firma" :options="companies" v-model="selectedCompanyId" sm row required />
|
||||
</tt-modal>
|
||||
`,
|
||||
data() { return { companies: [], selectedCompanyId: null } },
|
||||
async mounted() {
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getCompanies`);
|
||||
this.companies = response.data;
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.selectedCompanyId) return window.notify('error', 'Bitte eine Firma auswählen.');
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/assignWorkorder`, {
|
||||
workorderId: this.workorderId,
|
||||
companyId: this.selectedCompanyId
|
||||
});
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('close');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Modal for Company to schedule an appointment
|
||||
Vue.component('schedule-appointment-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<tt-modal :show="true" title="Termin festlegen" @submit="submit" @update:show="$emit('close')">
|
||||
<tt-date-picker label="Termindatum" :date-range="false" v-model="appointmentDate" sm row required />
|
||||
</tt-modal>
|
||||
`,
|
||||
data() { return { appointmentDate: null } },
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.appointmentDate) return window.notify('error', 'Bitte ein Datum auswählen.');
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/scheduleAppointment`, {
|
||||
workorderId: this.workorderId,
|
||||
appointmentDate: this.appointmentDate
|
||||
});
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('close');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Documentation Upload Modal for Companies
|
||||
Vue.component('documentation-modal', {
|
||||
props: ['workorder'],
|
||||
template: `
|
||||
<tt-modal :show="true" :title="'Dokumentation für Auftrag #' + workorder.id" :save="false" :delete="false" @update:show="$emit('close')">
|
||||
<div class="mb-3">
|
||||
<h5>Benötigte Dokumente</h5>
|
||||
<ul>
|
||||
<li v-for="docType in requiredDocTypes" :key="docType.value">
|
||||
{{ docType.text }}
|
||||
<i v-if="isUploaded(docType.value)" class="fas fa-check-circle text-success"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Neues Dokument hochladen</h5>
|
||||
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
|
||||
<tt-input label="Beschreibung (optional)" v-model="uploadData.description" sm row />
|
||||
<div class="form-group row">
|
||||
<label class="col-form-label col-sm-4 col-form-label-sm">Datei</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="file" class="form-control-file" @change="handleFileUpload" ref="fileInput" />
|
||||
</div>
|
||||
</div>
|
||||
<tt-button text="Hochladen" @click="uploadFile" :loading="uploading" additional-class="btn-primary float-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<documentation-viewer-modal :workorder-id="workorder.id" :key="viewerKey" />
|
||||
|
||||
<template v-slot:footer>
|
||||
<tt-button text="Auftrag abschließen" @click="completeWorkorder" :disabled="!canComplete" additional-class="btn-success" />
|
||||
<button class="btn btn-secondary" @click="$emit('close')">Schließen</button>
|
||||
</template>
|
||||
</tt-modal>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
uploading: false,
|
||||
viewerKey: 0,
|
||||
uploadedFiles: [],
|
||||
uploadData: {
|
||||
file: null,
|
||||
documentType: 'photo_before',
|
||||
description: ''
|
||||
},
|
||||
requiredDocTypes: [
|
||||
{ value: 'photo_before', text: 'Foto: Zustand vorher' },
|
||||
{ value: 'photo_during', text: 'Foto: Während der Arbeit' },
|
||||
{ value: 'photo_after', text: 'Foto: Zustand nachher' },
|
||||
{ value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' },
|
||||
{ value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' },
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canComplete() {
|
||||
// Check if at least one of each required document type is uploaded.
|
||||
return this.requiredDocTypes.every(docType => this.isUploaded(docType.value));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isUploaded(docType) {
|
||||
return this.uploadedFiles.some(file => file.documentType === docType);
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
this.uploadData.file = event.target.files[0];
|
||||
},
|
||||
async uploadFile() {
|
||||
if(!this.uploadData.file) return window.notify('error', 'Bitte eine Datei auswählen.');
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.uploadData.file);
|
||||
formData.append('workorderId', this.workorder.id);
|
||||
formData.append('documentType', this.uploadData.documentType);
|
||||
formData.append('description', this.uploadData.description);
|
||||
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/uploadDocumentation`, formData);
|
||||
if(response.data.success) {
|
||||
window.notify('success', `Datei "${response.data.fileName}" wurde hochgeladen.`);
|
||||
this.$refs.fileInput.value = ''; // Clear file input
|
||||
this.uploadData.file = null;
|
||||
this.uploadData.description = '';
|
||||
this.viewerKey++; // Refresh the viewer
|
||||
} else {
|
||||
window.notify('error', response.data.error || 'Upload fehlgeschlagen.');
|
||||
}
|
||||
this.uploading = false;
|
||||
},
|
||||
async completeWorkorder() {
|
||||
if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/completeWorkorder`, { workorderId: this.workorder.id });
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('close');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDocumentation`, { params: { workorderId: this.workorder.id }});
|
||||
this.uploadedFiles = response.data;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Read-only viewer for documentation, used by both Admins and Companies
|
||||
Vue.component('documentation-viewer-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Hochgeladene Dokumente</h5>
|
||||
</div>
|
||||
<div v-if="loading" class="card-body text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
<div v-else-if="!docs.length" class="card-body text-center text-muted">
|
||||
Keine Dokumente vorhanden.
|
||||
</div>
|
||||
<ul v-else class="list-group list-group-flush">
|
||||
<li v-for="doc in docs" :key="doc.id" class="list-group-item">
|
||||
<a :href="'/File/download?id=' + doc.fileId" target="_blank">
|
||||
<i class="fas fa-file-download"></i> {{ doc.fileName }}
|
||||
</a>
|
||||
<div class="text-muted small">
|
||||
<strong>Typ:</strong> {{ getDocTypeText(doc.documentType) }} <br/>
|
||||
<strong>Beschreibung:</strong> {{ doc.description || '-' }} <br/>
|
||||
<strong>Hochgeladen von:</strong> {{ doc.createBy }} am {{ formatDate(doc.create) }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return { loading: false, docs: [] }
|
||||
},
|
||||
methods: {
|
||||
async fetchDocs() {
|
||||
this.loading = true;
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDocumentation`, { params: { workorderId: this.workorderId }});
|
||||
this.docs = response.data;
|
||||
this.loading = false;
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
|
||||
},
|
||||
getDocTypeText(type) {
|
||||
const types = [
|
||||
{ value: 'photo_before', text: 'Foto: Zustand vorher' },
|
||||
{ value: 'photo_during', text: 'Foto: Während der Arbeit' },
|
||||
{ value: 'photo_after', text: 'Foto: Zustand nachher' },
|
||||
{ value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' },
|
||||
{ value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' },
|
||||
];
|
||||
return types.find(t => t.value === type)?.text || type;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchDocs();
|
||||
}
|
||||
});
|
||||
183
public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js
Normal file
183
public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// RMLWorkorderAdmin.js
|
||||
Vue.component('r-m-l-workorder-admin', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<assign-company-modal
|
||||
v-if="assignModalWorkorderId"
|
||||
:workorder-id="assignModalWorkorderId"
|
||||
@close="assignModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
|
||||
<documentation-viewer-modal
|
||||
v-if="docsModalWorkorderId"
|
||||
:workorder-id="docsModalWorkorderId"
|
||||
@close="docsModalWorkorderId = null"
|
||||
/>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
@assign="assignModalWorkorderId = $event.id"
|
||||
@view_docs="docsModalWorkorderId = $event.id"
|
||||
:crud-config="crudConfig"
|
||||
>
|
||||
<template v-slot:preorderinfo="{ row }">
|
||||
<div v-html="row.preorderInfo" class="small"></div>
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
||||
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
||||
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:deadlinedate="{ row }">
|
||||
{{ formatDate(row.deadlineDate) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:appointmentdate="{ row }">
|
||||
{{ formatDate(row.appointmentDate) }}
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
assignModalWorkorderId: null,
|
||||
docsModalWorkorderId: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
additionalActions: [
|
||||
{
|
||||
"key": "assign",
|
||||
"title": "Firma zuweisen",
|
||||
"class": "fas fa-user-plus text-primary",
|
||||
"condition": (row) => row.status === 'new',
|
||||
},
|
||||
{
|
||||
"key": "view_docs",
|
||||
"title": "Dokumentation ansehen",
|
||||
"class": "fas fa-folder-open text-info",
|
||||
"condition": (row) => ['documented', 'completed'].includes(row.status),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('traffic-light', {
|
||||
props: ['deadline', 'status'],
|
||||
computed: {
|
||||
lightInfo() {
|
||||
if (['completed', 'new'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
|
||||
const now = moment();
|
||||
const deadlineDate = moment.unix(this.deadline);
|
||||
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
|
||||
|
||||
if (deadlineDate.isBefore(now)) return { color: '#dc3545', title: 'Deadline überschritten' };
|
||||
const daysLeft = deadlineDate.diff(now, 'days');
|
||||
if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' };
|
||||
if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' };
|
||||
return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' };
|
||||
}
|
||||
},
|
||||
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">●</span>`
|
||||
});
|
||||
|
||||
Vue.component('assign-company-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<tt-modal :show="true" title="Firma zuweisen" @submit="submit" @update:show="$emit('close')">
|
||||
<tt-select label="Firma" :options="companies" v-model="selectedCompanyId" sm row required />
|
||||
</tt-modal>
|
||||
`,
|
||||
data() { return { companies: [], selectedCompanyId: null } },
|
||||
async mounted() {
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getCompanies`);
|
||||
this.companies = response.data;
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.selectedCompanyId) return window.notify('error', 'Bitte eine Firma auswählen.');
|
||||
try {
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/assignWorkorder`, {
|
||||
workorderId: this.workorderId,
|
||||
companyId: this.selectedCompanyId
|
||||
});
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('close');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('documentation-viewer-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<tt-modal :show="true" :title="'Dokumentation für Auftrag #' + workorderId" :save="false" :delete="false" @update:show="$emit('close')">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Hochgeladene Dokumente</h5>
|
||||
</div>
|
||||
<div v-if="loading" class="card-body text-center"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||
<div v-else-if="!docs.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
|
||||
<ul v-else class="list-group list-group-flush">
|
||||
<li v-for="doc in docs" :key="doc.id" class="list-group-item">
|
||||
<a :href="'/File/download?id=' + doc.fileId" target="_blank">
|
||||
<i class="fas fa-file-download mr-2"></i> {{ doc.fileName }}
|
||||
</a>
|
||||
<div class="text-muted small mt-1">
|
||||
<strong>Typ:</strong> {{ getDocTypeText(doc.documentType) }} <br/>
|
||||
<strong>Beschreibung:</strong> {{ doc.description || '-' }} <br/>
|
||||
<strong>Hochgeladen von:</strong> {{ doc.userName }} am {{ formatDate(doc.create) }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</tt-modal>
|
||||
`,
|
||||
data() {
|
||||
return { loading: false, docs: [] }
|
||||
},
|
||||
methods: {
|
||||
async fetchDocs() {
|
||||
this.loading = true;
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getDocumentation`, { params: { workorderId: this.workorderId }});
|
||||
this.docs = response.data;
|
||||
this.loading = false;
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
|
||||
},
|
||||
getDocTypeText(type) {
|
||||
const types = [
|
||||
{ value: 'photo_before', text: 'Foto: Zustand vorher' },
|
||||
{ value: 'photo_during', text: 'Foto: Während der Arbeit' },
|
||||
{ value: 'photo_after', text: 'Foto: Zustand nachher' },
|
||||
{ value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' },
|
||||
{ value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' },
|
||||
];
|
||||
return types.find(t => t.value === type)?.text || type;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchDocs();
|
||||
}
|
||||
});
|
||||
283
public/js/pages/RMLWorkorderCompany/RMLWorkorderCompany.js
Normal file
283
public/js/pages/RMLWorkorderCompany/RMLWorkorderCompany.js
Normal file
@@ -0,0 +1,283 @@
|
||||
// RMLWorkorderCompany.js
|
||||
|
||||
Vue.component('r-m-l-workorder-company', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<schedule-appointment-modal
|
||||
v-if="scheduleModalWorkorderId"
|
||||
:workorder-id="scheduleModalWorkorderId"
|
||||
@close="scheduleModalWorkorderId = null; $refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
@schedule="scheduleModalWorkorderId = $event.id"
|
||||
:crud-config="crudConfig"
|
||||
>
|
||||
<template v-slot:preorderinfo="{ row }">
|
||||
<div v-html="row.preorderInfo" class="small"></div>
|
||||
</template>
|
||||
|
||||
<template v-slot:status="{ row }">
|
||||
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
|
||||
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
|
||||
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:deadlinedate="{ row }">
|
||||
{{ formatDate(row.deadlineDate) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:appointmentdate="{ row }">
|
||||
{{ formatDate(row.appointmentDate) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<documentation-manager
|
||||
:workorder-id="row.id"
|
||||
@workorder-completed="$refs.table.$refs.table.refreshTable()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
scheduleModalWorkorderId: null,
|
||||
crudConfig: {
|
||||
...window.TT_CONFIG.CRUD_CONFIG,
|
||||
expandable: true,
|
||||
additionalActions: [
|
||||
{
|
||||
"key": "schedule",
|
||||
"title": "Termin festlegen",
|
||||
"class": "fas fa-calendar-plus text-primary",
|
||||
"condition": (row) => row.status === 'assigned',
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getStatusColumn(status) {
|
||||
const column = this.crudConfig.columns.find(c => c.key === 'status');
|
||||
return column.table.filterOptions.find(opt => opt.value === status) || {};
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '–';
|
||||
return window.moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('traffic-light', {
|
||||
props: ['deadline', 'status'],
|
||||
computed: {
|
||||
lightInfo() {
|
||||
if (['completed', 'new'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
|
||||
const now = moment();
|
||||
const deadlineDate = moment.unix(this.deadline);
|
||||
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
|
||||
|
||||
if (deadlineDate.isBefore(now)) return { color: '#dc3545', title: 'Deadline überschritten' };
|
||||
const daysLeft = deadlineDate.diff(now, 'days');
|
||||
if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' };
|
||||
if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' };
|
||||
return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' };
|
||||
}
|
||||
},
|
||||
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">●</span>`
|
||||
});
|
||||
|
||||
Vue.component('schedule-appointment-modal', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<tt-modal :show="true" title="Termin festlegen" @submit="submit" @update:show="$emit('close')">
|
||||
<tt-date-picker label="Termindatum" :date-range="false" v-model="appointmentDate" sm row required />
|
||||
</tt-modal>
|
||||
`,
|
||||
data() { return { appointmentDate: null } },
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.appointmentDate) return window.notify('error', 'Bitte ein Datum auswählen.');
|
||||
try {
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/scheduleAppointment`, {
|
||||
workorderId: this.workorderId,
|
||||
appointmentDate: this.appointmentDate
|
||||
});
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('close');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('documentation-manager', {
|
||||
props: ['workorderId'],
|
||||
template: `
|
||||
<div class="p-3 bg-light" style="width: 100%;">
|
||||
<div v-if="loadingWorkorder" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||
<div v-else class="row">
|
||||
<div class="col-lg-4 mb-3 mb-lg-0">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Benötigte Dokumente</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li v-for="docType in requiredDocTypes" :key="docType.value" class="mb-2 d-flex align-items-center">
|
||||
<i :class="isUploaded(docType.value) ? 'fas fa-check-circle text-success' : 'far fa-circle text-muted'" class="fa-fw mr-2"></i>
|
||||
<span>{{ docType.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<tt-button
|
||||
text="Auftrag abschließen"
|
||||
@click="completeWorkorder"
|
||||
:disabled="!canComplete || workorder.status === 'completed'"
|
||||
:loading="completing"
|
||||
additional-class="btn-success w-100"
|
||||
icon="fas fa-check-double"
|
||||
/>
|
||||
<small v-if="!canComplete" class="form-text text-muted text-center mt-2">
|
||||
Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen.
|
||||
</small>
|
||||
<div v-if="workorder.status === 'completed'" class="alert alert-secondary text-center mt-2 p-2">
|
||||
Auftrag bereits abgeschlossen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-3" v-if="workorder.status !== 'completed'">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Neues Dokument hochladen</h5>
|
||||
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
|
||||
<tt-input label="Beschreibung" v-model="uploadData.description" sm row placeholder="Optional"/>
|
||||
<div class="form-group row">
|
||||
<label class="col-form-label col-sm-4 col-form-label-sm">Dateien</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="file" class="form-control-file form-control-sm" @change="handleFileUpload" ref="fileInput" multiple />
|
||||
</div>
|
||||
</div>
|
||||
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading" additional-class="btn-primary float-right" icon="fas fa-upload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tt-file-gallery :files="uploadedFiles" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
loadingWorkorder: true,
|
||||
workorder: null,
|
||||
uploading: false,
|
||||
completing: false,
|
||||
uploadedFiles: [],
|
||||
uploadData: {
|
||||
files: [],
|
||||
documentType: 'photo_before',
|
||||
description: ''
|
||||
},
|
||||
requiredDocTypes: [
|
||||
{ value: 'photo_before', text: 'Foto: Zustand vorher' },
|
||||
{ value: 'photo_during', text: 'Foto: Während der Arbeit' },
|
||||
{ value: 'photo_after', text: 'Foto: Zustand nachher' },
|
||||
{ value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' },
|
||||
{ value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' },
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canComplete() {
|
||||
return this.requiredDocTypes.every(docType => this.isUploaded(docType.value));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadWorkorder() {
|
||||
this.loadingWorkorder = true;
|
||||
try {
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getWorkorderById`, { params: { id: this.workorderId }});
|
||||
this.workorder = response.data;
|
||||
} catch(e) {
|
||||
window.notify('error', 'Arbeitsauftragsdetails konnten nicht geladen werden.');
|
||||
}
|
||||
this.loadingWorkorder = false;
|
||||
},
|
||||
isUploaded(docType) {
|
||||
return this.uploadedFiles.some(file => file.documentType === docType);
|
||||
},
|
||||
async fetchDocs() {
|
||||
try {
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getDocumentation`, { params: { workorderId: this.workorderId }});
|
||||
this.uploadedFiles = response.data;
|
||||
} catch(e) {
|
||||
window.notify('error', 'Dokumente konnten nicht geladen werden.');
|
||||
}
|
||||
},
|
||||
handleFileUpload(event) {
|
||||
this.uploadData.files = event.target.files;
|
||||
},
|
||||
async uploadFiles() {
|
||||
if(!this.uploadData.files || this.uploadData.files.length === 0) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.');
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('workorderId', this.workorder.id);
|
||||
formData.append('documentType', this.uploadData.documentType);
|
||||
formData.append('description', this.uploadData.description);
|
||||
for (const file of this.uploadData.files) {
|
||||
formData.append('files[]', file);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/uploadDocumentation`, formData);
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$refs.fileInput.value = '';
|
||||
this.uploadData.files = [];
|
||||
this.uploadData.description = '';
|
||||
await this.fetchDocs();
|
||||
await this.loadWorkorder(); // Reload to get updated status
|
||||
} else {
|
||||
window.notify('error', response.data.error || 'Upload fehlgeschlagen.');
|
||||
}
|
||||
} catch(e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
|
||||
}
|
||||
this.uploading = false;
|
||||
},
|
||||
async completeWorkorder() {
|
||||
if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
|
||||
this.completing = true;
|
||||
try {
|
||||
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/completeWorkorder`, { workorderId: this.workorder.id });
|
||||
if(response.data.success) {
|
||||
window.notify('success', response.data.message);
|
||||
this.$emit('workorder-completed');
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch(e) {
|
||||
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
|
||||
}
|
||||
this.completing = false;
|
||||
},
|
||||
getDocTypeText(type) {
|
||||
const found = this.requiredDocTypes.find(t => t.value === type);
|
||||
return found ? found.text : type;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadWorkorder();
|
||||
await this.fetchDocs();
|
||||
}
|
||||
});
|
||||
@@ -554,321 +554,245 @@ Vue.component('radius', {
|
||||
|
||||
Vue.component('radius-ont-finder', {
|
||||
template: `
|
||||
<div class="container mt-4">
|
||||
<div v-if="step === 1" class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Schritt 1: Excel (XLSX) Upload</h4>
|
||||
<p>Bitte laden Sie eine XLSX-Datei hoch, die mindestens eine Spalte mit dem Header 'Serial' für die ONT-Seriennummern enthält. Erwartete Spalten (optional, aber zur Anzeige empfohlen): Nummer, Serial, ONT-Type, Meter, Pegel.</p>
|
||||
<input type="file" class="form-control" @change="handleFileUpload" accept=".xlsx">
|
||||
<div v-if="uploadError" class="alert alert-danger mt-3">{{ uploadError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 2" class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Schritt 2: Ergebnisse</h4>
|
||||
<button class="btn btn-primary mb-3" @click="downloadResults">
|
||||
<i class="fas fa-download mr-2"></i>Ergebnisse herunterladen
|
||||
</button>
|
||||
<button class="btn btn-secondary mb-3 ml-2" @click="resetComponent">
|
||||
<i class="fas fa-redo mr-2"></i>Neue Datei hochladen
|
||||
</button>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered table-sm">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th v-for="header in originalHeaders" :key="'orig-' + header">{{ header }}</th>
|
||||
<th>Username</th>
|
||||
<th>Kundennummer</th>
|
||||
<th>Kundenname</th>
|
||||
<th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in processedData" :key="index">
|
||||
<td v-for="header in originalHeaders" :key="'data-' + header + '-' + index">{{ row[header] }}</td>
|
||||
<td>{{ row.fetched_username }}</td>
|
||||
<td>{{ row.fetched_customerNumber }}</td>
|
||||
<td>{{ row.fetched_customerName }}</td>
|
||||
<td>{{ row.fetched_info }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay mt-4">
|
||||
<div class="d-flex justify-content-center align-items-center flex-column">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<div class="progress w-75">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
:style="{ width: progress + '%' }"
|
||||
:aria-valuenow="progress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
{{ Math.round(progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}</div>
|
||||
<div v-if="currentSerial" class="text-center text-muted small mt-1">Aktuelle ONT SN: {{ currentSerial }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container mt-4">
|
||||
<div v-if="step === 1" class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Schritt 1: Excel (XLSX) Upload</h4>
|
||||
<p>Bitte laden Sie eine XLSX-Datei hoch, die mindestens eine Spalte mit dem Header 'Serial' für die ONT-Seriennummern enthält. Optional kann eine 'MAC' Spalte für eine alternative Suche verwendet werden.</p>
|
||||
<input type="file" class="form-control" @change="handleFileUpload" accept=".xlsx">
|
||||
<div v-if="uploadError" class="alert alert-danger mt-3">{{ uploadError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="step === 2" class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Schritt 2: Ergebnisse</h4>
|
||||
<button class="btn btn-primary mb-3" @click="downloadResults">
|
||||
<i class="fas fa-download mr-2"></i>Ergebnisse herunterladen
|
||||
</button>
|
||||
<button class="btn btn-secondary mb-3 ml-2" @click="resetComponent">
|
||||
<i class="fas fa-redo mr-2"></i>Neue Datei hochladen
|
||||
</button>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered table-sm">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th v-for="header in originalHeaders" :key="'orig-' + header">{{ header }}</th>
|
||||
<th>Username</th>
|
||||
<th>Kundennummer</th>
|
||||
<th>Kundenname</th>
|
||||
<th>Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in processedData" :key="index">
|
||||
<td v-for="header in originalHeaders" :key="'data-' + header + '-' + index">{{ row[header] }}</td>
|
||||
<td>{{ row.fetched_username }}</td>
|
||||
<td>{{ row.fetched_customerNumber }}</td>
|
||||
<td>{{ row.fetched_customerName }}</td>
|
||||
<td>{{ row.fetched_info }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay mt-4">
|
||||
<div class="d-flex justify-content-center align-items-center flex-column">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<div class="progress w-75">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar" :style="{ width: progress + '%' }"
|
||||
:aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100">
|
||||
{{ Math.round(progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">Verarbeite Zeile {{ currentRow + 1 }} von {{ totalRows }}</div>
|
||||
<div v-if="currentSerial" class="text-center text-muted small mt-1">Aktuelle Suche: {{ currentSerial }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
data() {
|
||||
return {
|
||||
step: 1, // 1: Upload, 2: Results
|
||||
parsedData: [], // Raw data from XLSX
|
||||
processedData: [], // Data after API calls
|
||||
originalHeaders: [], // Headers from the uploaded XLSX
|
||||
step: 1,
|
||||
parsedData: [],
|
||||
processedData: [],
|
||||
originalHeaders: [],
|
||||
loading: false,
|
||||
progress: 0,
|
||||
currentRow: 0,
|
||||
totalRows: 0,
|
||||
currentSerial: '', // Track the serial being processed for display
|
||||
uploadError: null, // To display errors during upload/parsing
|
||||
// Define the key column name expected in the XLSX for the ONT Serial Number
|
||||
serialColumnName: 'Serial', // IMPORTANT: Adjust if the header name in the XLSX is different
|
||||
// Define keys for the fetched data to avoid conflicts with original headers
|
||||
currentSerial: '',
|
||||
uploadError: null,
|
||||
serialColumnName: 'Serial',
|
||||
macColumnName: 'MAC',
|
||||
fetchedKeys: {
|
||||
username: 'fetched_username',
|
||||
customerNumber: 'fetched_customerNumber',
|
||||
customerName: 'fetched_customerName',
|
||||
info: 'fetched_info'
|
||||
},
|
||||
// Base path for the API - ensure TT_CONFIG is available globally
|
||||
apiBasePath: window.TT_CONFIG ? window.TT_CONFIG['BASE_PATH'] : '/default/path/to/api' // Provide a fallback or handle error if TT_CONFIG is missing
|
||||
apiBasePath: window.TT_CONFIG?.BASE_PATH
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Resets the component state to allow a new file upload.
|
||||
*/
|
||||
resetComponent() {
|
||||
this.step = 1;
|
||||
this.parsedData = [];
|
||||
this.processedData = [];
|
||||
this.originalHeaders = [];
|
||||
this.loading = false;
|
||||
this.progress = 0;
|
||||
this.currentRow = 0;
|
||||
this.totalRows = 0;
|
||||
this.currentSerial = '';
|
||||
this.uploadError = null;
|
||||
// Reset the file input visually (optional, requires ref)
|
||||
Object.assign(this.$data, this.$options.data.call(this));
|
||||
const input = this.$el.querySelector('input[type="file"]');
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
if (input) input.value = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles the file input change event.
|
||||
* Loads the XLSX library if needed, reads the file,
|
||||
* parses the data, validates the 'Serial' column,
|
||||
* and triggers processing.
|
||||
* @param {Event} event - The file input change event.
|
||||
*/
|
||||
async handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
this.uploadError = null; // Clear previous errors
|
||||
this.uploadError = null;
|
||||
if (!file) return;
|
||||
|
||||
this.loading = true; // Show loading indicator early
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
// Load XLSX library dynamically if not already loaded
|
||||
await this.loadXLSX();
|
||||
const data = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(new Uint8Array(e.target.result));
|
||||
reader.onerror = () => reject(new Error("Fehler beim Lesen der Datei."));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result);
|
||||
const workbook = XLSX.read(data, { type: "array" });
|
||||
const worksheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[worksheetName];
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
this.parsedData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
||||
|
||||
// Parse the sheet into an array of objects, automatically detecting headers
|
||||
this.parsedData = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); // Use defval to handle empty cells
|
||||
if (!this.parsedData.length) {
|
||||
throw new Error("Die hochgeladene Datei ist leer oder konnte nicht gelesen werden.");
|
||||
}
|
||||
|
||||
if (this.parsedData.length === 0) {
|
||||
this.uploadError = "Die hochgeladene Datei ist leer oder konnte nicht gelesen werden.";
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get headers from the first row of parsed data
|
||||
this.originalHeaders = Object.keys(this.parsedData[0]);
|
||||
|
||||
// --- Validation: Check if the required 'Serial' column exists ---
|
||||
if (!this.originalHeaders.includes(this.serialColumnName)) {
|
||||
this.uploadError = `Fehler: Die erforderliche Spalte '${this.serialColumnName}' wurde in der hochgeladenen Datei nicht gefunden. Gefundene Spalten: ${this.originalHeaders.join(', ')}`;
|
||||
this.loading = false;
|
||||
this.parsedData = []; // Clear data if invalid
|
||||
this.originalHeaders = [];
|
||||
// Keep step at 1 to show the error
|
||||
return;
|
||||
}
|
||||
// --- End Validation ---
|
||||
|
||||
// If validation passes, proceed to processing
|
||||
this.startProcessing();
|
||||
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing XLSX file:", parseError);
|
||||
this.uploadError = `Fehler beim Verarbeiten der XLSX-Datei: ${parseError.message}`;
|
||||
this.loading = false;
|
||||
this.step = 1; // Stay on upload step
|
||||
}
|
||||
};
|
||||
reader.onerror = (err) => {
|
||||
console.error("FileReader error:", err);
|
||||
this.uploadError = "Fehler beim Lesen der Datei.";
|
||||
this.loading = false;
|
||||
this.step = 1;
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
} catch (libLoadError) {
|
||||
console.error("Error loading XLSX library:", libLoadError);
|
||||
this.uploadError = "Fehler beim Laden der erforderlichen Bibliothek (xlsx). Bitte versuchen Sie es erneut.";
|
||||
this.originalHeaders = Object.keys(this.parsedData[0]);
|
||||
if (!this.originalHeaders.includes(this.serialColumnName)) {
|
||||
throw new Error(`Erforderliche Spalte '${this.serialColumnName}' nicht gefunden.`);
|
||||
}
|
||||
this.startProcessing();
|
||||
} catch (error) {
|
||||
console.error("File processing error:", error);
|
||||
this.uploadError = error.message;
|
||||
this.loading = false;
|
||||
this.step = 1;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Dynamically loads the SheetJS (XLSX) library if it's not already available.
|
||||
*/
|
||||
async loadXLSX() {
|
||||
if (!window.XLSX) {
|
||||
console.log("Loading XLSX library...");
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js'; // Consider using a newer version if available
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
console.log("XLSX library loaded.");
|
||||
resolve();
|
||||
};
|
||||
script.onerror = (err) => {
|
||||
console.error("Failed to load XLSX script:", err);
|
||||
reject(new Error("Could not load XLSX library"));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
if (window.XLSX) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.17.0/xlsx.full.min.js';
|
||||
script.async = true;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error("Could not load XLSX library."));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Processes the parsed data row by row, fetching details from the Radius API.
|
||||
*/
|
||||
async startProcessing() {
|
||||
this.loading = true;
|
||||
this.totalRows = this.parsedData.length;
|
||||
this.processedData = []; // Clear previous results
|
||||
this.processedData = [];
|
||||
this.progress = 0;
|
||||
this.currentRow = 0;
|
||||
this.currentSerial = '';
|
||||
|
||||
const apiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=`;
|
||||
const notFoundMessage = 'N/A - Keinen Benutzer mit dieser ONT SN gefunden';
|
||||
const snApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?ont_sn=`;
|
||||
const macApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?username=`;
|
||||
const sesApiUrlBase = `${this.apiBasePath}/Radius/proxyUnsecureHTTPRequestToRadius?action2=find_by_current_session&mac=`;
|
||||
|
||||
for (let i = 0; i < this.parsedData.length; i++) {
|
||||
const setRowStatus = (row, msg, data = {}) => {
|
||||
const defaultData = { username: `N/A - ${msg}`, customerNumber: 'N/A', customerName: 'N/A', info: 'N/A' };
|
||||
Object.keys(this.fetchedKeys).forEach(key => row[this.fetchedKeys[key]] = data[key] || defaultData[key]);
|
||||
};
|
||||
|
||||
for (const [i, row] of this.parsedData.entries()) {
|
||||
this.currentRow = i;
|
||||
const row = { ...this.parsedData[i] }; // Create a copy to avoid modifying original parsed data
|
||||
const serialNumber = row[this.serialColumnName]?.trim(); // Get serial number, trim whitespace
|
||||
|
||||
this.currentSerial = serialNumber || 'Leer'; // Update display
|
||||
this.progress = ((i + 1) / this.totalRows) * 100;
|
||||
|
||||
// Initialize fetched data fields
|
||||
row[this.fetchedKeys.username] = '';
|
||||
row[this.fetchedKeys.customerNumber] = '';
|
||||
row[this.fetchedKeys.customerName] = '';
|
||||
row[this.fetchedKeys.info] = '';
|
||||
const newRow = { ...row };
|
||||
const serialNumber = row[this.serialColumnName]?.trim();
|
||||
this.currentSerial = `SN: ${serialNumber || 'Leer'}`;
|
||||
|
||||
if (!serialNumber) {
|
||||
// Handle rows with empty serial numbers
|
||||
row[this.fetchedKeys.username] = 'N/A - Leere Seriennummer';
|
||||
row[this.fetchedKeys.customerNumber] = 'N/A';
|
||||
row[this.fetchedKeys.customerName] = 'N/A';
|
||||
row[this.fetchedKeys.info] = 'N/A';
|
||||
this.processedData.push(row);
|
||||
await this.sleep(10); // Small delay for UI update even for empty rows
|
||||
continue; // Move to the next row
|
||||
setRowStatus(newRow, 'Leere Seriennummer');
|
||||
this.processedData.push(newRow);
|
||||
this.progress = ((i + 1) / this.totalRows) * 100;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrlBase + encodeURIComponent(serialNumber));
|
||||
if (!response.ok) {
|
||||
// Handle HTTP errors (e.g., 404, 500)
|
||||
console.error(`API Error for SN ${serialNumber}: ${response.status} ${response.statusText}`);
|
||||
row[this.fetchedKeys.username] = `N/A - API Fehler (${response.status})`;
|
||||
row[this.fetchedKeys.customerNumber] = 'N/A';
|
||||
row[this.fetchedKeys.customerName] = 'N/A';
|
||||
row[this.fetchedKeys.info] = 'N/A';
|
||||
} else {
|
||||
const data = await response.json();
|
||||
let found = false;
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
// Assuming the first result is the relevant one if multiple are returned
|
||||
const userData = data[0];
|
||||
row[this.fetchedKeys.username] = userData.username || 'N/A';
|
||||
row[this.fetchedKeys.customerNumber] = userData.customerNumber || 'N/A';
|
||||
row[this.fetchedKeys.customerName] = userData.customerName || 'N/A';
|
||||
row[this.fetchedKeys.info] = userData.info || 'N/A';
|
||||
} else {
|
||||
// Handle case where API returns success but an empty array or unexpected format
|
||||
row[this.fetchedKeys.username] = notFoundMessage;
|
||||
row[this.fetchedKeys.customerNumber] = 'N/A';
|
||||
row[this.fetchedKeys.customerName] = 'N/A';
|
||||
row[this.fetchedKeys.info] = 'N/A';
|
||||
try {
|
||||
const snResponse = await fetch(snApiUrlBase + encodeURIComponent(serialNumber));
|
||||
if (snResponse.ok) {
|
||||
const snData = await snResponse.json();
|
||||
if (snData?.length > 0) {
|
||||
setRowStatus(newRow, '', snData[0]);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for SN ${serialNumber}:`, error);
|
||||
row[this.fetchedKeys.username] = 'N/A - Fehler bei API-Abfrage';
|
||||
row[this.fetchedKeys.customerNumber] = 'N/A';
|
||||
row[this.fetchedKeys.customerName] = 'N/A';
|
||||
row[this.fetchedKeys.info] = 'N/A';
|
||||
console.error(`Fetch error for SN ${serialNumber}:`, error);
|
||||
}
|
||||
|
||||
this.processedData.push(row);
|
||||
if (!found && this.originalHeaders.includes(this.macColumnName)) {
|
||||
const macAddress = row[this.macColumnName]?.trim();
|
||||
this.currentSerial = `MAC: ${macAddress || 'Leer'}`;
|
||||
if (macAddress && macAddress.length === 12) {
|
||||
const formattedMac = macAddress.toUpperCase().match(/.{1,2}/g).join(':');
|
||||
try {
|
||||
const sesResponse = await fetch(`${sesApiUrlBase}${encodeURIComponent(formattedMac)}`);
|
||||
if (sesResponse.ok) {
|
||||
const sesData = await sesResponse.json();
|
||||
if (sesData?.length === 0) continue;
|
||||
|
||||
// Optional small delay to prevent UI freeze on large files and allow progress update
|
||||
if (i % 20 === 0) { // Update UI roughly every 20 rows
|
||||
await this.sleep(20);
|
||||
const username = sesData[0];
|
||||
|
||||
const macResponse = await fetch(`${macApiUrlBase}${encodeURIComponent(username)}&info=&custnum=`);
|
||||
if (macResponse.ok) {
|
||||
const macData = await macResponse.json();
|
||||
if (macData?.length > 0) {
|
||||
setRowStatus(newRow, '', macData[0]);
|
||||
console.log("found via MAC:", formattedMac, macData[0]);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Fetch error for MAC ${formattedMac}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
setRowStatus(newRow, 'Keinen Benutzer gefunden');
|
||||
}
|
||||
|
||||
this.processedData.push(newRow);
|
||||
this.progress = ((i + 1) / this.totalRows) * 100;
|
||||
if ((i + 1) % 20 === 0) await this.sleep(20);
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.step = 2; // Move to results view
|
||||
this.currentSerial = ''; // Clear serial display
|
||||
this.step = 2;
|
||||
this.currentSerial = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates and triggers the download of an XLSX file containing the processed results.
|
||||
*/
|
||||
downloadResults() {
|
||||
if (!this.processedData.length) return;
|
||||
|
||||
try {
|
||||
// Prepare data for export: Select and order columns
|
||||
const dataToExport = this.processedData.map(row => {
|
||||
const exportRow = {};
|
||||
// Include original columns first
|
||||
this.originalHeaders.forEach(header => {
|
||||
exportRow[header] = row[header];
|
||||
});
|
||||
// Add fetched data with user-friendly headers
|
||||
this.originalHeaders.forEach(header => { exportRow[header] = row[header]; });
|
||||
exportRow['Username'] = row[this.fetchedKeys.username];
|
||||
exportRow['Kundennummer'] = row[this.fetchedKeys.customerNumber];
|
||||
exportRow['Kundenname'] = row[this.fetchedKeys.customerName];
|
||||
@@ -876,47 +800,25 @@ Vue.component('radius-ont-finder', {
|
||||
return exportRow;
|
||||
});
|
||||
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(dataToExport);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "ONT_Finder_Results"); // Sheet name
|
||||
|
||||
// Generate filename (e.g., results_YYYYMMDD_HHMMSS.xlsx)
|
||||
XLSX.utils.book_append_sheet(wb, ws, "ONT_Finder_Results");
|
||||
const timestamp = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 14);
|
||||
const filename = `ont_finder_results_${timestamp}.xlsx`;
|
||||
|
||||
XLSX.writeFile(wb, filename);
|
||||
XLSX.writeFile(wb, `ont_finder_results_${timestamp}.xlsx`);
|
||||
} catch (error) {
|
||||
console.error("Error generating results file:", error);
|
||||
alert("Fehler beim Erstellen der Excel-Datei für den Download."); // Simple alert for user feedback
|
||||
alert("Fehler beim Erstellen der Excel-Datei für den Download.");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Utility function to pause execution for a specified duration.
|
||||
* Useful for allowing UI updates during long loops.
|
||||
* @param {number} ms - Milliseconds to sleep.
|
||||
*/
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lifecycle hook called when the component is mounted.
|
||||
* Checks if the global TT_CONFIG is available.
|
||||
*/
|
||||
mounted() {
|
||||
if (!window.TT_CONFIG || !window.TT_CONFIG['BASE_PATH']) {
|
||||
console.warn("Global TT_CONFIG or TT_CONFIG['BASE_PATH'] not found. API calls may fail. Using fallback path:", this.apiBasePath);
|
||||
// Optionally display a warning to the user
|
||||
// this.uploadError = "Konfiguration für API-Pfad nicht gefunden. Funktionalität möglicherweise beeinträchtigt.";
|
||||
} else {
|
||||
// Update apiBasePath if TT_CONFIG was found after initial data setup (less likely but safe)
|
||||
this.apiBasePath = window.TT_CONFIG['BASE_PATH'];
|
||||
if (!window.TT_CONFIG?.BASE_PATH) {
|
||||
console.warn(`Global TT_CONFIG.BASE_PATH not found. API calls will use fallback path: ${this.apiBasePath}`);
|
||||
}
|
||||
// Ensure XLSX is loaded once the component is ready, in case it's needed immediately
|
||||
// Although handleFileUpload loads it, pre-loading might be slightly smoother if needed elsewhere later
|
||||
// this.loadXLSX().catch(err => console.error("Pre-loading XLSX library failed:", err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -37,6 +37,21 @@ Vue.component('WarehouseArticlePacket', {
|
||||
articles: [],
|
||||
}
|
||||
}, beforeMount() {
|
||||
// Dynamically filter articles based on the user's shop context
|
||||
// This assumes TT_CONFIG is available and contains userAddressId
|
||||
const userAddressId = window['TT_CONFIG']['userAddressId'];
|
||||
let articleFilter = {};
|
||||
if (userAddressId === 209) {
|
||||
articleFilter = { isEShop: 1 };
|
||||
} else if (userAddressId === 210) {
|
||||
articleFilter = { isSbidiShop: 1 };
|
||||
}
|
||||
|
||||
// The current implementation directly uses `window['TT_CONFIG']['CRUD_CONFIG'].columns.find(...)`
|
||||
// which might not reflect the filtered articles.
|
||||
// To properly filter, the `input-article` component or its underlying API call needs to be aware of the shop context.
|
||||
// For now, this will just get all articles that were passed from the backend during initial config.
|
||||
// A more robust solution would involve modifying the `WarehouseArticle/autocomplete` API to accept shop filters.
|
||||
this.articles = window['TT_CONFIG']['CRUD_CONFIG'].columns.find(column => column.key === 'subItems').modal.items;
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
Vue.component('warehouse-e-shop', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<tt-card>
|
||||
<tt-expandable-shopping-cart :cart-items="shoppingCart" @submitOrder="openOrderDialog"/>
|
||||
<tt-card>
|
||||
<tt-expandable-shopping-cart :cart-items="shoppingCart" @submitOrder="openOrderDialog"/>
|
||||
|
||||
<tt-modal :show.sync="createOrderDialog"
|
||||
:delete="false"
|
||||
title="Bestellung abschicken"
|
||||
@submit="submitOrder">
|
||||
|
||||
<tt-select v-model="createOrderDialogData.deliveryMode" label="Adresse" :options="[
|
||||
<tt-modal :show.sync="createOrderDialog"
|
||||
:delete="false"
|
||||
title="Bestellung abschicken"
|
||||
@submit="submitOrder">
|
||||
|
||||
<tt-select v-model="createOrderDialogData.deliveryMode" label="Adresse" :options="[
|
||||
{text: 'Einzelne Adresse', value: 'singleAddress'},
|
||||
// {text: 'Mehrere Adressen', value: 'multipleAddresses'},
|
||||
]" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.extRef" label="Externe Referenz" sm row/>
|
||||
]" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.extRef" label="Externe Referenz" sm row/>
|
||||
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressName" label="Name" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressMail" label="E-Mail" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressPhone" label="Nummer" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressAdditional" label="Anschriftenzusatz" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressLine" label="Straße" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressPLZ" label="PLZ" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressCity" label="Stadt" sm row/>
|
||||
|
||||
</tt-modal>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressName" label="Name" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressMail" label="E-Mail" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressPhone" label="Nummer" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressAdditional" label="Anschriftenzusatz" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressLine" label="Straße" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressPLZ" label="PLZ" sm row/>
|
||||
<tt-input v-model="createOrderDialogData.deliveryAddressCity" label="Stadt" sm row/>
|
||||
|
||||
<tt-table-crud>
|
||||
</tt-modal>
|
||||
|
||||
<template v-slot:price="{ row }">
|
||||
<span v-if="row.hasOwnProperty('calculatedSellPrice')"> {{ row.calculatedSellPrice.toFixed(2) }} €</span>
|
||||
<span v-else>{{
|
||||
Array.isArray(JSON.parse(row.cheapestSellPrice)) ? JSON.parse(row.cheapestSellPrice).find(price => price.title === 'Energie Steiermark').price.toFixed(2) :
|
||||
Object.values(JSON.parse(row.cheapestSellPrice)).find(price => price.title === 'Energie Steiermark').price.toFixed(2) }} €</span>
|
||||
</template>
|
||||
<tt-table-crud>
|
||||
|
||||
<template v-slot:amount="{ row }">
|
||||
<!-- this has no padding - add a full width full height tt-input with -->
|
||||
<tt-input type="number" style="width: 100%; height: 100%;margin:0 !important" v-model="itemAmounts[row.hasOwnProperty('calculatedSellPrice') ? 'P-' + row.id : 'I-' + row.id]" sm/>
|
||||
</template>
|
||||
|
||||
<template v-slot:add="{ row }">
|
||||
<a style="cursor: pointer;" @click="addToCart(row, row.hasOwnProperty('calculatedSellPrice') ? 'P' : 'I')">
|
||||
<i class="fas fa-shopping-cart text-primary"></i>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`, data() {
|
||||
<template v-slot:price="{ row }">
|
||||
<span v-if="row.hasOwnProperty('calculatedSellPrice')"> {{ row.calculatedSellPrice.toFixed(2) }} €</span>
|
||||
<span v-else>
|
||||
{{ getArticlePrice(row).toFixed(2) }} €
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:amount="{ row }">
|
||||
<!-- this has no padding - add a full width full height tt-input with -->
|
||||
<tt-input type="number" style="width: 100%; height: 100%;margin:0 !important" v-model="itemAmounts[row.hasOwnProperty('calculatedSellPrice') ? 'P-' + row.id : 'I-' + row.id]" sm/>
|
||||
</template>
|
||||
|
||||
<template v-slot:add="{ row }">
|
||||
<a style="cursor: pointer;" @click="addToCart(row, row.hasOwnProperty('calculatedSellPrice') ? 'P' : 'I')">
|
||||
<i class="fas fa-shopping-cart text-primary"></i>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`, data() {
|
||||
return {
|
||||
window: window, itemAmounts: {}, shoppingCart: [], createOrderDialog: false, createOrderDialogData: {
|
||||
deliveryMode: 'singleAddress',
|
||||
@@ -59,9 +59,29 @@ Vue.component('warehouse-e-shop', {
|
||||
deliveryAddressPLZ: '',
|
||||
deliveryAddressCity: '',
|
||||
},
|
||||
userAddressId: window['TT_CONFIG']['userAddressId'] || null, // Get user's address ID from PHP
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getArticlePrice(row) {
|
||||
const cheapestSellPrice = JSON.parse(row.cheapestSellPrice);
|
||||
let priceTitle = '';
|
||||
|
||||
if (this.userAddressId === 209) {
|
||||
priceTitle = 'Energie Steiermark';
|
||||
} else if (this.userAddressId === 9633) {
|
||||
priceTitle = 'SBIDI';
|
||||
} else {
|
||||
// Default or error handling if addressId is not recognized
|
||||
return 0;
|
||||
}
|
||||
|
||||
const foundPrice = Array.isArray(cheapestSellPrice)
|
||||
? cheapestSellPrice.find(price => price.title === priceTitle)
|
||||
: Object.values(cheapestSellPrice).find(price => price.title === priceTitle);
|
||||
|
||||
return foundPrice ? foundPrice.price : 0;
|
||||
},
|
||||
async openOrderDialog() {
|
||||
this.createOrderDialog = true;
|
||||
},
|
||||
@@ -81,6 +101,7 @@ Vue.component('warehouse-e-shop', {
|
||||
if (response.data.success) {
|
||||
this.window.notify('success', response.data.message || 'Erfolgreich gespeichert');
|
||||
this.shoppingCart = [];
|
||||
this.itemAmounts = {}; // Clear item amounts after successful order
|
||||
this.createOrderDialogData = {
|
||||
deliveryMode: 'singleAddress',
|
||||
extRef: '',
|
||||
|
||||
@@ -45,7 +45,7 @@ Vue.component('warehouse-e-shop-order-modal-positions-mgmt', {
|
||||
}
|
||||
|
||||
for (const response of articlePacketResponses) {
|
||||
this.$set(this.articlePacketNames, response.data[0].value, response.data[0].text);
|
||||
this.$set(this.articlePacketNames, response.data[0].value, response.data[0].data[0].text); // Adjusted for packet autocomplete response structure
|
||||
}
|
||||
|
||||
},
|
||||
@@ -109,47 +109,47 @@ Vue.component('warehouse-e-shop-order-modal-positions-mgmt', {
|
||||
},
|
||||
}, watch: {positions: {handler: 'fetchNames', immediate: true}}, //language=Vue
|
||||
template: `
|
||||
<div>
|
||||
<div>
|
||||
|
||||
<div style="display: grid;grid-template-columns: 2fr 1fr 0.5fr 1fr;grid-gap: 10px;">
|
||||
<tt-autocomplete v-model="articleId" :api-url="articleAPIUrl" label="Artikel" sm ref="article"/>
|
||||
<tt-autocomplete v-model="articlePacketId" :api-url="articlePacketAPIUrl" label="Artikel Packet" sm/>
|
||||
<tt-input v-model="amount" label="Menge" sm/>
|
||||
<div style="display: flex;flex-direction: column;justify-content: center;padding-top: 13px;">
|
||||
<button @click="addPosition" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid;grid-template-columns: 2fr 1fr 0.5fr 1fr;grid-gap: 10px;">
|
||||
<tt-autocomplete v-model="articleId" :api-url="articleAPIUrl" label="Artikel" sm ref="article"/>
|
||||
<tt-autocomplete v-model="articlePacketId" :api-url="articlePacketAPIUrl" label="Artikel Packet" sm/>
|
||||
<tt-input v-model="amount" label="Menge" sm/>
|
||||
<div style="display: flex;flex-direction: column;justify-content: center;padding-top: 13px;">
|
||||
<button @click="addPosition" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center;">
|
||||
<table class="table table-striped table-sm" style="width: max-content">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikel</th>
|
||||
<th>Menge</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="positions.length === 0">
|
||||
<td colspan="4" class="text-center">Keine Einträge</td>
|
||||
</tr>
|
||||
<tr v-for="(position, index) in positions" :key="index">
|
||||
<td>{{ position.articleId ? articleNames[position.articleId] : position.articlePacketId ? articlePacketNames[position.articlePacketId] :
|
||||
'Loading...' }}
|
||||
</td>
|
||||
<td>{{ position.quantity }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" @click="deletePosition(position.id)">Löschen</button>
|
||||
<button class="btn btn-sm btn-primary" @click="editPosition(position.id)">Bearbeiten</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-content: center;">
|
||||
<table class="table table-striped table-sm" style="width: max-content">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikel</th>
|
||||
<th>Menge</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="positions.length === 0">
|
||||
<td colspan="4" class="text-center">Keine Einträge</td>
|
||||
</tr>
|
||||
<tr v-for="(position, index) in positions" :key="index">
|
||||
<td>{{ position.articleId ? articleNames[position.articleId] : position.articlePacketId ? articlePacketNames[position.articlePacketId] :
|
||||
'Loading...' }}
|
||||
</td>
|
||||
<td>{{ position.quantity }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" @click="deletePosition(position.id)">Löschen</button>
|
||||
<button class="btn btn-sm btn-primary" @click="editPosition(position.id)">Bearbeiten</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
`,
|
||||
</div>
|
||||
`,
|
||||
|
||||
|
||||
})
|
||||
@@ -157,83 +157,89 @@ Vue.component('warehouse-e-shop-order-modal-positions-mgmt', {
|
||||
Vue.component('warehouse-e-shop-order', {
|
||||
//language=Vue
|
||||
template: `
|
||||
<tt-card>
|
||||
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"
|
||||
@openSingleOrderEmail="openSingleOrderEmailModal = true; singleOrderEmailModalId = $event.id"
|
||||
@createShippingNote="createShippingNote($event.id)"
|
||||
@showTrackingHistory="openTrackingHistoryModal = true; trackingHistoryModalId = $event.id"
|
||||
ref="table">
|
||||
<tt-card>
|
||||
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"
|
||||
@openSingleOrderEmail="openSingleOrderEmailModal = true; singleOrderEmailModalId = $event.id"
|
||||
@createShippingNote="createShippingNote($event.id)"
|
||||
@showTrackingHistory="openTrackingHistoryModal = true; trackingHistoryModalId = $event.id"
|
||||
ref="table">
|
||||
|
||||
<template v-slot:table-top-buttons>
|
||||
<button @click="createCSVExportAndMarkAsAccepted" type="button" class="btn btn-outline-success">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Excel Export für neue Bestellungen
|
||||
</button>
|
||||
</template>
|
||||
<template v-slot:table-top-buttons>
|
||||
<button @click="createCSVExportAndMarkAsAccepted" type="button" class="btn btn-outline-success">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Excel Export für neue Bestellungen
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-slot:deliveryaddressname="{ row }">
|
||||
{{ row.deliveryAddressName }} {{ row.deliveryAddressAdditional ? '(' + row.deliveryAddressAdditional + ')' : '' }}
|
||||
</template>
|
||||
<template v-slot:deliveryaddressname="{ row }">
|
||||
{{ row.deliveryAddressName }} {{ row.deliveryAddressAdditional ? '(' + row.deliveryAddressAdditional + ')' : '' }}
|
||||
</template>
|
||||
|
||||
<template v-slot:create="{ row }">
|
||||
{{ window.moment(row.create * 1000).format('DD.MM.YYYY HH:mm:ss') }}
|
||||
</template>
|
||||
<template v-slot:create="{ row }">
|
||||
{{ window.moment(row.create * 1000).format('DD.MM.YYYY HH:mm:ss') }}
|
||||
</template>
|
||||
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div>
|
||||
<ul class="list-group" v-if="articleItems && articleItems[row.id]">
|
||||
<li class="list-group-item" v-for="item in articleItems[row.id]">
|
||||
Menge: {{ item.quantity }} | {{ item.articlePacketTitle || item.articleTitle }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:addressid="{ row }">
|
||||
<span v-if="row.addressId === 209">Energie Steiermark</span>
|
||||
<span v-else-if="row.addressId === 9633">SBIDI</span>
|
||||
<span v-else>Unbekannt</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:positions-modal="{ crudModalData }">
|
||||
<div>
|
||||
<warehouse-e-shop-order-modal-positions-mgmt :id="crudModalData.id"/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:expandedRow="{ row }">
|
||||
<div>
|
||||
<ul class="list-group" v-if="articleItems && articleItems[row.id]">
|
||||
<li class="list-group-item" v-for="item in articleItems[row.id]">
|
||||
Menge: {{ item.quantity }} | {{ item.articlePacketTitle || item.articleTitle }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</tt-table-crud>
|
||||
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
|
||||
<template v-slot:positions-modal="{ crudModalData }">
|
||||
<div>
|
||||
<warehouse-e-shop-order-modal-positions-mgmt :id="crudModalData.id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- add a tt-modal which just shows some text from the api to show a email for a single order-->
|
||||
<tt-modal :show.sync="openSingleOrderEmailModal"
|
||||
:title="'Email für Bestellung ' + singleOrderEmailModalId"
|
||||
@close="openSingleOrderEmailModal = false"
|
||||
save-text="E-Mail senden"
|
||||
@submit="sendSingleOrderEmail"
|
||||
:delete="false"
|
||||
>
|
||||
<div v-if="singleOrderEmailModalId && singleOrderEmailModalText">
|
||||
<p v-html="singleOrderEmailModalText.body.replaceAll('\\n', '<br>')"></p>
|
||||
</div>
|
||||
<!-- else show loader-->
|
||||
<div v-else>
|
||||
<tt-loader/>
|
||||
</div>
|
||||
</tt-modal>
|
||||
</tt-table-crud>
|
||||
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
|
||||
|
||||
<tt-modal :show.sync="openTrackingHistoryModal"
|
||||
:title="'Tracking Historie für Bestellung ' + trackingHistoryModalId"
|
||||
@close="openTrackingHistoryModal = false"
|
||||
:delete="false"
|
||||
:save="false"
|
||||
>
|
||||
<div v-if="trackingHistoryModalData">
|
||||
<ul class="list-group">
|
||||
<li class="list-group item" v-for="entry in trackingHistoryModalData.history">
|
||||
<strong>{{ entry.date }} {{ entry.time }}</strong> - {{ entry.evtDscr }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- add a tt-modal which just shows some text from the api to show a email for a single order-->
|
||||
<tt-modal :show.sync="openSingleOrderEmailModal"
|
||||
:title="'Email für Bestellung ' + singleOrderEmailModalId"
|
||||
@close="openSingleOrderEmailModal = false"
|
||||
save-text="E-Mail senden"
|
||||
@submit="sendSingleOrderEmail"
|
||||
:delete="false"
|
||||
>
|
||||
<div v-if="singleOrderEmailModalId && singleOrderEmailModalText">
|
||||
<p v-html="singleOrderEmailModalText.body.replaceAll('\\n', '<br>')"></p>
|
||||
</div>
|
||||
<!-- else show loader-->
|
||||
<div v-else>
|
||||
<tt-loader/>
|
||||
</div>
|
||||
</tt-modal>
|
||||
|
||||
</tt-modal>
|
||||
<tt-modal :show.sync="openTrackingHistoryModal"
|
||||
:title="'Tracking Historie für Bestellung ' + trackingHistoryModalId"
|
||||
@close="openTrackingHistoryModal = false"
|
||||
:delete="false"
|
||||
:save="false"
|
||||
>
|
||||
<div v-if="trackingHistoryModalData">
|
||||
<ul class="list-group">
|
||||
<li class="list-group item" v-for="entry in trackingHistoryModalData.history">
|
||||
<strong>{{ entry.date }} {{ entry.time }}</strong> - {{ entry.evtDscr }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</tt-modal>
|
||||
|
||||
|
||||
</tt-card>
|
||||
`, data() {
|
||||
</tt-card>
|
||||
`, data() {
|
||||
return {
|
||||
window: window,
|
||||
historyModal: false,
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const articleTag = (() => {
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments.length > 0 ? segments[0] : 'DefaultTag';
|
||||
})();
|
||||
const linkEl = document.getElementById('bookstackLink');
|
||||
if (!linkEl) return;
|
||||
|
||||
const articleTag = window.location.pathname.split('/').filter(Boolean)[0] || 'DefaultTag';
|
||||
const cacheKey = `bookstack_article_${articleTag}`;
|
||||
|
||||
const apiUrl = `https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(articleTag)}%5D%7Btype%3Apage%7D`;
|
||||
const linkElement = document.getElementById('bookstackLink');
|
||||
const setupLinkAction = url => {
|
||||
linkEl.style.display = 'block';
|
||||
linkEl.querySelector('a').onclick = e => {
|
||||
e.preventDefault();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'bookstack-integration-modal';
|
||||
modal.innerHTML = `<div class="bookstack-integration-modal-content"><button class="bookstack-integration-close-btn">×</button><iframe src="${url}?iframe=true" class="bookstack-integration-iframe"></iframe></div>`;
|
||||
modal.onclick = ev => {
|
||||
if (ev.target === modal || ev.target.classList.contains('bookstack-integration-close-btn')) {
|
||||
modal.remove();
|
||||
}
|
||||
};
|
||||
document.body.appendChild(modal);
|
||||
};
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key === 'F8') {
|
||||
e.preventDefault();
|
||||
localStorage.removeItem(cacheKey);
|
||||
window.notify('success', `📗 BookStack cache für '${articleTag}' wurde gelöscht.`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Authorization': 'Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i'
|
||||
}
|
||||
const cachedItem = JSON.parse(localStorage.getItem(cacheKey) || 'null');
|
||||
if (cachedItem && (Date.now() - cachedItem.timestamp < (cachedItem.url ? 604800000 : 259200000))) {
|
||||
if (cachedItem.url) setupLinkAction(cachedItem.url);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(articleTag)}%5D%7Btype%3Apage%7D`, {
|
||||
headers: { 'Authorization': 'Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i' }
|
||||
});
|
||||
const data = await response.json();
|
||||
const articleUrl = data.data?.[0]?.url || null;
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
const article = data.data[0];
|
||||
linkElement.style.display = 'block';
|
||||
linkElement.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
showArticleModal(article.url);
|
||||
});
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ url: articleUrl, timestamp: Date.now() }));
|
||||
articleUrl ? setupLinkAction(articleUrl) : (linkEl.style.display = 'none');
|
||||
} catch (error) {
|
||||
console.error('BookStack API error:', error);
|
||||
linkElement.style.display = 'none';
|
||||
linkEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function showArticleModal(url) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'bookstack-integration-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="bookstack-integration-modal-content">
|
||||
<button class="bookstack-integration-close-btn">×</button>
|
||||
<iframe src="${url}?iframe=true" class="bookstack-integration-iframe"></iframe>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.querySelector('.bookstack-integration-close-btn').addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
});
|
||||
});
|
||||
1
public/plugins/bookstack/bookstackIntegration.min.js
vendored
Normal file
1
public/plugins/bookstack/bookstackIntegration.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
document.addEventListener("DOMContentLoaded",(async()=>{const t=document.getElementById("bookstackLink");if(!t)return;const e=window.location.pathname.split("/").filter(Boolean)[0]||"DefaultTag",o=`bookstack_article_${e}`,a=e=>{t.style.display="block",t.querySelector("a").onclick=t=>{t.preventDefault();const o=document.createElement("div");o.className="bookstack-integration-modal",o.innerHTML=`<div class="bookstack-integration-modal-content"><button class="bookstack-integration-close-btn">×</button><iframe src="${e}?iframe=true" class="bookstack-integration-iframe"></iframe></div>`,o.onclick=t=>{(t.target===o||t.target.classList.contains("bookstack-integration-close-btn"))&&o.remove()},document.body.appendChild(o)}};document.addEventListener("keydown",(t=>{t.ctrlKey&&"F8"===t.key&&(t.preventDefault(),localStorage.removeItem(o),window.notify("success",`📗 BookStack cache für '${e}' wurde gelöscht.`))}));try{const n=JSON.parse(localStorage.getItem(o)||"null");if(n&&Date.now()-n.timestamp<(n.url?6048e5:2592e5))return void(n.url&&a(n.url));const c=await fetch(`https://bookstack.xinon.at/api/search?query=%5Bshowurl%3D${encodeURIComponent(e)}%5D%7Btype%3Apage%7D`,{headers:{Authorization:"Token XmGSDWlg3bZhHKXFchNXQ9LpXvCaBuM1:k6XNe6RUU1BIxkv5pxpZ9PSErqZbHJ4i"}}),r=await c.json(),s=r.data?.[0]?.url||null;localStorage.setItem(o,JSON.stringify({url:s,timestamp:Date.now()})),s?a(s):t.style.display="none"}catch(e){console.error("BookStack API error:",e),t.style.display="none"}}));
|
||||
181
public/plugins/vue/tt-components/css/tt-file-gallery.css
Normal file
181
public/plugins/vue/tt-components/css/tt-file-gallery.css
Normal file
@@ -0,0 +1,181 @@
|
||||
.tt-file-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tt-file-gallery-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt-file-gallery-thumbnail {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid #dee2e6;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.tt-file-gallery-item:hover .tt-file-gallery-thumbnail {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tt-file-gallery-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 40px; /* Adjust to not cover filename */
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.tt-file-gallery-item:hover .tt-file-gallery-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tt-file-gallery-icon-container {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #f8f9fa;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tt-file-gallery-filename {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* --- Fullscreen Viewer Styles --- */
|
||||
|
||||
.tt-fullscreen-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tt-fullscreen-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.tt-fullscreen-btn {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tt-fullscreen-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.tt-fullscreen-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tt-fullscreen-image-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden; /* Important for panning */
|
||||
}
|
||||
|
||||
.tt-fullscreen-image {
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
object-fit: contain;
|
||||
will-change: transform; /* Performance hint for browser */
|
||||
}
|
||||
|
||||
.tt-fullscreen-pdf {
|
||||
width: calc(100vw - 40px);
|
||||
height: calc(100vh - 40px);
|
||||
max-width: 1600px; /* Optional: max width for very large screens */
|
||||
border: none;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.tt-fullscreen-nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tt-fullscreen-nav-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.tt-fullscreen-nav-btn.left {
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
.tt-fullscreen-nav-btn.right {
|
||||
right: 15px;
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
text-align: center; /* Center text */
|
||||
}
|
||||
|
||||
/* Make tooltip visible when showTooltip is true */
|
||||
/* Make tooltip visible on hover */
|
||||
.tt-tooltip-wrapper:hover .tt-tooltip-box {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -89,7 +89,8 @@
|
||||
border-color: transparent #333 transparent transparent;
|
||||
}
|
||||
|
||||
/* The problematic 'width: 100% !important;' has been removed from the selector below.
|
||||
*/
|
||||
.tt-tooltip-wrapper > * {
|
||||
display: inline-block; /* Ensure the tooltip wrapper behaves correctly */
|
||||
width: 100% !important;
|
||||
}
|
||||
@@ -18,9 +18,6 @@ Vue.component('tt-checkbox', {
|
||||
this.checkedValue = val;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('input', this.checkedValue);
|
||||
},
|
||||
template: `
|
||||
<div class="form-group" :class="{'row': row}">
|
||||
<slot name="prepend"></slot>
|
||||
|
||||
233
public/plugins/vue/tt-components/tt-file-gallery.js
Normal file
233
public/plugins/vue/tt-components/tt-file-gallery.js
Normal file
@@ -0,0 +1,233 @@
|
||||
Vue.component('tt-file-gallery', {
|
||||
props: {
|
||||
files: { type: Array, default: () => [] }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fullscreenItem: null, // Holds the file being viewed
|
||||
currentImageIndex: 0,
|
||||
|
||||
// Zoom & Pan state
|
||||
zoom: 1,
|
||||
pan: { x: 0, y: 0 },
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
lastPinchDist: 0,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
imageFiles() {
|
||||
return this.files.filter(this.isImage);
|
||||
},
|
||||
isViewingImage() {
|
||||
return this.fullscreenItem && this.isImage(this.fullscreenItem);
|
||||
},
|
||||
imageTransformStyle() {
|
||||
// Apply CSS transform for zoom and pan
|
||||
const { x, y } = this.pan;
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px) scale(${this.zoom})`,
|
||||
cursor: this.isPanning ? 'grabbing' : 'grab',
|
||||
transition: this.isPanning ? 'none' : 'transform 0.2s',
|
||||
};
|
||||
},
|
||||
fullscreenDownloadUrl() {
|
||||
if (!this.fullscreenItem) return '#';
|
||||
return `/File/download?id=${this.fullscreenItem.id}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// File type checks
|
||||
isImage(file) {
|
||||
return file.mimetype && file.mimetype.startsWith('image/');
|
||||
},
|
||||
isPdf(file) {
|
||||
return file.mimetype === 'application/pdf';
|
||||
},
|
||||
|
||||
// Get icon for non-image/pdf files
|
||||
getFileIcon(file) {
|
||||
const extension = file.fileName?.split('.').pop().toLowerCase();
|
||||
switch (extension) {
|
||||
case 'doc':
|
||||
case 'docx': return 'fas fa-file-word text-primary';
|
||||
case 'xls':
|
||||
case 'xlsx': return 'fas fa-file-excel text-success';
|
||||
case 'zip':
|
||||
case 'rar': return 'fas fa-file-archive text-warning';
|
||||
default: return 'fas fa-file text-secondary';
|
||||
}
|
||||
},
|
||||
|
||||
// Viewer controls
|
||||
openViewer(file) {
|
||||
this.fullscreenItem = file;
|
||||
if (this.isImage(file)) {
|
||||
this.currentImageIndex = this.imageFiles.findIndex(img => img.id === file.id);
|
||||
}
|
||||
this.resetZoomAndPan();
|
||||
this.$nextTick(() => { this.$refs.viewer?.focus(); });
|
||||
},
|
||||
closeViewer() {
|
||||
this.fullscreenItem = null;
|
||||
},
|
||||
navigateImage(direction) {
|
||||
const newIndex = this.currentImageIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < this.imageFiles.length) {
|
||||
this.currentImageIndex = newIndex;
|
||||
this.fullscreenItem = this.imageFiles[newIndex];
|
||||
this.resetZoomAndPan();
|
||||
}
|
||||
},
|
||||
|
||||
// Event handlers for keyboard and clicks
|
||||
handleKeyDown(event) {
|
||||
if (!this.fullscreenItem) return;
|
||||
switch (event.key) {
|
||||
case 'Escape': this.closeViewer(); break;
|
||||
case 'ArrowLeft': this.isViewingImage && this.navigateImage(-1); break;
|
||||
case 'ArrowRight': this.isViewingImage && this.navigateImage(1); break;
|
||||
}
|
||||
},
|
||||
|
||||
// --- Zoom and Pan Methods ---
|
||||
resetZoomAndPan() {
|
||||
this.zoom = 1;
|
||||
this.pan = { x: 0, y: 0 };
|
||||
this.isPanning = false;
|
||||
},
|
||||
|
||||
// Mouse Wheel Zoom
|
||||
handleWheel(e) {
|
||||
if (!this.isViewingImage) return;
|
||||
e.preventDefault();
|
||||
const scaleFactor = 0.2;
|
||||
const newZoom = this.zoom - (e.deltaY > 0 ? scaleFactor : -scaleFactor);
|
||||
this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom between 1x and 5x
|
||||
},
|
||||
|
||||
// Mouse Drag to Pan
|
||||
onPanStart(e) {
|
||||
if (this.zoom <= 1) return;
|
||||
e.preventDefault();
|
||||
this.isPanning = true;
|
||||
this.panStart.x = e.clientX - this.pan.x;
|
||||
this.panStart.y = e.clientY - this.pan.y;
|
||||
},
|
||||
onPanMove(e) {
|
||||
if (!this.isPanning) return;
|
||||
this.pan.x = e.clientX - this.panStart.x;
|
||||
this.pan.y = e.clientY - this.panStart.y;
|
||||
},
|
||||
onPanEnd() {
|
||||
this.isPanning = false;
|
||||
},
|
||||
|
||||
// Touch Events for Mobile (Pinch-to-Zoom & Pan)
|
||||
onTouchStart(e) {
|
||||
if (this.zoom <= 1 && e.touches.length === 1) return;
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 1) { // Pan
|
||||
this.isPanning = true;
|
||||
this.panStart.x = e.touches[0].clientX - this.pan.x;
|
||||
this.panStart.y = e.touches[0].clientY - this.pan.y;
|
||||
} else if (e.touches.length === 2) { // Zoom
|
||||
this.lastPinchDist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
}
|
||||
},
|
||||
onTouchMove(e) {
|
||||
if (!this.isPanning && e.touches.length !== 2) return;
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 1 && this.isPanning) { // Pan
|
||||
this.pan.x = e.touches[0].clientX - this.panStart.x;
|
||||
this.pan.y = e.touches[0].clientY - this.panStart.y;
|
||||
} else if (e.touches.length === 2) { // Zoom
|
||||
const pinchDist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
const scaleFactor = 0.01;
|
||||
const newZoom = this.zoom + (pinchDist - this.lastPinchDist) * scaleFactor;
|
||||
this.zoom = Math.max(1, Math.min(newZoom, 5)); // Clamp zoom
|
||||
this.lastPinchDist = pinchDist;
|
||||
}
|
||||
},
|
||||
onTouchEnd(e) {
|
||||
this.isPanning = false;
|
||||
if (e.touches.length < 2) {
|
||||
this.lastPinchDist = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
fullscreenItem(newItem) {
|
||||
// Prevent body scroll when viewer is open
|
||||
document.body.style.overflow = newItem ? 'hidden' : '';
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="card">
|
||||
<div class="card-header"><h5>Hochgeladene Dokumente</h5></div>
|
||||
<div v-if="!files.length" class="card-body text-center text-muted">Keine Dokumente vorhanden.</div>
|
||||
<div v-else class="card-body">
|
||||
<div class="tt-file-gallery-grid">
|
||||
<div v-for="file in files" :key="file.id" class="tt-file-gallery-item" @click="openViewer(file)">
|
||||
<template v-if="isImage(file)">
|
||||
<img :src="'/File/show?id=' + file.id + '&size=small'" class="tt-file-gallery-thumbnail" :alt="file.fileName">
|
||||
<div class="tt-file-gallery-overlay"><i class="fas fa-search-plus"></i></div>
|
||||
</template>
|
||||
<template v-else-if="isPdf(file)">
|
||||
<div class="tt-file-gallery-icon-container"><i class="fas fa-file-pdf fa-3x text-danger"></i></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="'/File/download?id=' + file.id" target="_blank" @click.stop class="tt-file-gallery-icon-container">
|
||||
<i :class="getFileIcon(file)" class="fa-3x"></i>
|
||||
</a>
|
||||
</template>
|
||||
<div class="tt-file-gallery-filename" :title="file.fileName">{{ file.fileName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fullscreenItem" class="tt-fullscreen-overlay" @click.self="closeViewer" @keydown="handleKeyDown" tabindex="-1" ref="viewer">
|
||||
<div class="tt-fullscreen-toolbar">
|
||||
<a v-if="isViewingImage" :href="fullscreenDownloadUrl" download class="tt-fullscreen-btn" title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<button class="tt-fullscreen-btn" @click="closeViewer" title="Close"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="tt-fullscreen-content" @click.self="closeViewer">
|
||||
<template v-if="isViewingImage">
|
||||
<div class="tt-fullscreen-image-wrapper"
|
||||
@wheel="handleWheel"
|
||||
@mousedown="onPanStart"
|
||||
@mousemove="onPanMove"
|
||||
@mouseup="onPanEnd"
|
||||
@mouseleave="onPanEnd"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd">
|
||||
<img :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-image" :style="imageTransformStyle" @click.stop />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isPdf(fullscreenItem)">
|
||||
<iframe :src="'/File/show?id=' + fullscreenItem.id" class="tt-fullscreen-pdf" @click.stop></iframe>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template v-if="isViewingImage">
|
||||
<button class="tt-fullscreen-nav-btn left" @click.stop="navigateImage(-1)" v-if="currentImageIndex > 0">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="tt-fullscreen-nav-btn right" @click.stop="navigateImage(1)" v-if="currentImageIndex < imageFiles.length - 1">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
@@ -181,7 +181,7 @@ Vue.component('tt-table-crud', {
|
||||
key: this.crudConfig.key,
|
||||
tableHeader: this.crudConfig.tableHeader,
|
||||
headers: this.crudConfig.columns.filter(column => column.table !== false).map(column => {
|
||||
return {text: column.text, key: column.key, ...column.table, filterOptions: column?.modal?.items, priority: column.priority}
|
||||
return {text: column.text, key: column.key, ...column.table, filterOptions: column?.table?.filterOptions ?? column?.modal?.items ?? [], priority: column.priority}
|
||||
})
|
||||
}
|
||||
}, modalConfig() {
|
||||
|
||||
@@ -201,9 +201,9 @@ Vue.component('tt-table', {
|
||||
.isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key])
|
||||
.format('DD.MM.YYYY HH:mm')) : ''
|
||||
}}</span>
|
||||
<i v-else-if="column.filter === 'iconSelect'"
|
||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
|
||||
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
|
||||
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
|
||||
<span v-else-if="column.filter === 'autocomplete'">
|
||||
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}
|
||||
|
||||
@@ -236,9 +236,9 @@ Vue.component('tt-table', {
|
||||
}}</span>
|
||||
<span v-else-if="column.filter === 'select'">{{ columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text }}</span>
|
||||
<span v-else-if="key === 'create'">{{ window.moment(row[key] * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
|
||||
<i v-else-if="column.filter === 'iconSelect'"
|
||||
:title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"
|
||||
:class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>
|
||||
<!-- <i v-else-if="column.filter === 'iconSelect'"-->
|
||||
<!-- :title="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text"-->
|
||||
<!-- :class="columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.icon"></i>-->
|
||||
<span v-else-if="column.filter === 'autocomplete'">
|
||||
{{ autoCompleteData[key] && autoCompleteData[key][row[key]] ? autoCompleteData[key][row[key]]['title'] : row[key] }}</span>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user