Feature/warehouse

This commit is contained in:
Luca Haid
2024-07-16 06:55:46 +00:00
parent 8b4589523e
commit e75f13f8d8
64 changed files with 3207 additions and 265 deletions

View File

@@ -12,9 +12,9 @@ $additionalCSS = $additionalCSS ?? [];
$additionalJS = $additionalJS ?? [];
$additionalJS = [
...$additionalJS,
"bundler.php",
"js/pages/" . $vueViewName . "/" . $vueViewName . ".js",
...$additionalJS,
];
$additionalCSS = [

View File

@@ -130,6 +130,27 @@
</li>
<?php endif; ?>
<?php if($me->is(["Admin"])&& isset($_GET['warehouse'])): ?>
<li class="has-submenu">
<a href="#">
<i class="fa-solid fa-warehouse"></i>Lager <div class="arrow-down"></div>
</a>
<ul class="submenu">
<!-- create links for WarehouseArticle, WarehouseDistributor, WarehouseLocation, WarehouseItem, WarehouseOrderRecommendation -->
<?php if($me->isAdmin() || $me->can("WarehouseArticle")): ?><li><a href="<?=self::getUrl("WarehouseArticle")?>?warehouse"><i class="far fa-fw fa-box text-info"></i> Artikel</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseDistributor")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>?warehouse"><i class="far fa-fw fa-truck text-info"></i> Lieferanten</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseArticlePriceType")): ?><li><a href="<?=self::getUrl("WarehouseArticlePriceType")?>?warehouse"><i class="far fa-fw fa-money-bill-wave text-info"></i> Preis Typen</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseLocation")): ?><li><a href="<?=self::getUrl("WarehouseLocation")?>?warehouse"><i class="far fa-fw fa-map-marker-alt text-info"></i> Lagerorte</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseItem")): ?><li><a href="<?=self::getUrl("WarehouseItem")?>?warehouse"><i class="far fa-fw fa-boxes text-info"></i> Lagerbestand</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseOrderRecommendation")): ?><li><a href="<?=self::getUrl("WarehouseOrderRecommendation")?>?warehouse"><i class="far fa-fw fa-box-full text-info"></i> Bestellvorschläge</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseEShop")): ?><li><a href="<?=self::getUrl("WarehouseEShop")?>?warehouse"><i class="far fa-fw fa-shopping-cart text-info"></i> E-Shop</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseEShopOrder")): ?><li><a href="<?=self::getUrl("WarehouseEShopOrder")?>?warehouse"><i class="far fa-fw fa-shopping-basket text-info"></i> E-Shop Bestellungen</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseOrder")): ?><li><a href="<?=self::getUrl("WarehouseOrder")?>?warehouse"><i class="far fa-fw fa-shopping-bag text-info"></i> Bestellungen</a></li><?php endif; ?>
<?php if($me->isAdmin() || $me->can("WarehouseShippingNote")): ?><li><a href="<?=self::getUrl("WarehouseShippingNote")?>?warehouse"><i class="far fa-fw fa-shipping-fast text-info"></i> Lieferscheine</a></li><?php endif; ?>
</ul>
</li>
<?php endif; ?>
<?php if($me->is(["Admin"]) || $me->can("Voipnumbering")): ?>
<li class="has-submenu mobile-hide">
<a href="#">

View File

@@ -24,20 +24,7 @@ class VoiceCallHistoryController extends mfBaseController {
}
protected function indexAction(): void {
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
"PAGE_TITLE" => "Voice Call History",
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
["text" => "Voice Call History", "href" => self::getUrl("VoiceCallHistory")]
],
"VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"),
];
$this->layout()->set("vueViewName", "VoiceCallHistory");
$this->layout()->set("JSGlobals", $JSGlobals);
$this->layout()->setTemplate("VueViews/Vue");
Helper::renderVue($this, "Voice Call History", $this->mod, ["VOICE_CALL_HISTORY_API_URL" => $this->getUrl("VoiceCallHistory", "api")]);
}
protected function apiAction() {

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseArticle extends mfBaseModel
{
}

View File

@@ -0,0 +1,188 @@
<?php
class WarehouseArticleController extends TTCrud {
protected string $headerTitle = 'Artikel';
protected string $createText = 'Artikel erstellen';
// @formatter:off
protected array $columns = [
['key' => 'title', 'text' => 'Titel', 'required' => true, 'table' => ['priority' => 9]],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true, 'table' => false],
['key' => 'category', 'text' => 'Kategorie', 'required' => true],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'warningAmount', 'text' => 'Warnmenge', 'modal' => ['type' => 'number'], 'table' => ['class' => 'text-center']], // Stock/inventory related
['key' => 'criticalAmount', 'text' => 'Kritische Menge', 'modal' => ['type' => 'number'], 'table' => ['class' => 'text-center']], // Stock/inventory related
['key' => 'isEShop', 'text' => 'Ist E-Shop', 'modal' => ['type' => 'checkbox'], 'table' => false], // Boolean value
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 8]]
];
protected array $additionalActions = [
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary'],
['key' => 'editDistributorEntries','title' => 'Lieferanten','class' => 'fas fa-truck text-cyan'],
['key' => 'editThresholdEntries','title' => 'Schwellenwerte','class' => 'far fa-fw fa-box-full text-orange'],
['key' => 'editPricesEntries','title' => 'Preise','class' => 'fas fa-euro-sign text-green'],
];
// @formatter:on
protected array $infoMessages = ['create' => 'Artikel wurde erstellt',
'update' => 'Artikel wurde aktualisiert',
'delete' => 'Artikel wurde gelöscht',
'noChanges' => 'Keine Änderungen',];
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function afterUpdate($postData) {
self::updateCheapestPurchasePrice($postData['id']);
}
public static function updateCheapestPurchasePrice($articleId) {
$cheapestPurchasePrice = WarehouseArticleDistributorModel::getAll(['articleId' => $articleId], 1, 0,
['key' => 'purchasePrice', 'order' => 'ASC'])[0]->purchasePrice;
$article = WarehouseArticleModel::get($articleId);
if (!$article instanceof WarehouseArticleModel) {
throw new Exception("Article is not an instance of WarehouseArticleModel");
}
//TODO: think of a new way as we have multiple sell prices now and article sellPriceOverride and sellPriceMultiplier do not exist anymore
// $cheapestSellPrice = $article->sellPriceOverride ?? $article->sellPriceMultiplier * $cheapestPurchasePrice;
if ($article->cheapestPurchasePrice != $cheapestPurchasePrice) {
WarehouseArticleModel::update([...get_object_vars($article), // Unpack properties into an array
'cheapestPurchasePrice' => $cheapestPurchasePrice]);
}
}
protected function afterCreate($postData) {
self::updateCheapestPurchasePrice($postData['id']);
}
protected function updateAllCheapestPurchasePricesAction() {
$articles = WarehouseArticleModel::getAll();
foreach ($articles as $article) {
self::updateCheapestPurchasePrice($article->id);
}
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
protected function importAction() {
error_reporting(E_ALL);
ini_set('display_errors', 1);
// read import.json from directory of this file
$json = fopen(dirname(__FILE__) . '/import.json', 'r') or die('Unable to open file!');
// read file content
$data = fread($json, filesize(dirname(__FILE__) . '/import.json'));
// decode json
$data = json_decode($data, true);
// close file
fclose($json);
// die with data length
// loop through data
echo 'Importing ' . count($data) . ' items' . PHP_EOL;
$count = 0;
foreach ($data as $item) {
// echo count + 1
echo ++$count . PHP_EOL;
// Check if Distributor exists
$distributor = WarehouseDistributorModel::getAll(['name' => $item['Lieferant']]);
if (empty($distributor)) {
$distributorId = WarehouseDistributorModel::create(['name' => $item['Lieferant'],
'address' => 'Missing',
'plz' => 'Missing',
'city' => 'Missing',
'countryId' => 1,
'email' => 'Missing',
'phone' => 'Missing',
'contactPerson' => 'Missing',]);
} else {
$distributorId = $distributor[0]->id;
}
// only continue if PRODUKT 1.ZEILE and PRODUKT 2.ZEILE and ARTIKEL GRUPPE and VK and EK and Lieferant/ Hersteller Artikelnr: are set
if (!isset($item['PRODUKT 1.ZEILE'])) {
echo 'Missing data for ' . $item['PRODUKT 1.ZEILE'] . PHP_EOL;
continue;
}
if (empty($item['VK'])) {
$item['VK'] = 0;
$calcSellPriceMultiplier = 0;
} else {
$item['VK'] = floatval(str_replace(',', '', $item['VK']));
}
if (empty($item['EK'])) {
$item['EK'] = 0;
$calcSellPriceMultiplier = 0;
$purchasePrice = 0;
} else {
$item['EK'] = floatval(str_replace(',', '', $item['EK']));
}
if (!empty($item['VK']) && !empty($item['EK'])) {
$calcSellPriceMultiplier = $item['VK'] / $item['EK'];
// if calcSellPriceMultiplier has more than 2 decimal places assign $calcSellPriceOverride
echo strlen(substr(strrchr($calcSellPriceMultiplier, "."), 1)) . PHP_EOL;
if (strlen(substr(strrchr($calcSellPriceMultiplier, "."), 1)) > 2) {
$calcSellPriceOverride = $item['VK'];
}
$purchasePrice = str_replace(',', '', $item['EK']);
}
if (!isset($calcSellPriceMultiplier) && !isset($calcSellPriceOverride) || !isset($purchasePrice)) {
echo 'Missing data for ' . $item['PRODUKT 1.ZEILE'] . PHP_EOL;
continue;
}
// Check if Article exists
$article = WarehouseArticleModel::getAll(['title' => ['exact' => $item['PRODUKT 1.ZEILE']]]);
if (empty($article)) {
$articleCreateData = ['title' => $item['PRODUKT 1.ZEILE'],
'description' => $item['PRODUKT 2. ZEILE'],
'category' => $item['ARTIKEL GRUPPE'],
'cheapestPurchasePrice' => 0,
'warningAmount' => 25,
'criticalAmount' => 10,
'isEShop' => 0,];
// if calcSellPriceOverride is set, add it to the $articleCreateData array
if (isset($calcSellPriceOverride)) {
$articleCreateData['sellPriceOverride'] = $calcSellPriceOverride;
} else if (isset($calcSellPriceMultiplier)) {
$articleCreateData['sellPriceMultiplier'] = $calcSellPriceMultiplier;
}
$articleId = WarehouseArticleModel::create($articleCreateData);
} else {
echo 'Article already exists with title ' . $item['PRODUKT 1.ZEILE'] . PHP_EOL;
$articleId = $article[0]->id;
}
// Check if ArticleDistributor exists
$articleDistributor = WarehouseArticleDistributorModel::getAll(['articleId' => $articleId,
'distributorId' => $distributorId]);
if (empty($articleDistributor)) {
WarehouseArticleDistributorModel::create(['articleId' => $articleId,
'distributorId' => $distributorId,
'purchasePrice' => $purchasePrice,
'externalArticleNumber' => $item['Lieferant/ Hersteller Artikelnr:'],]);
}
}
}
}

View File

@@ -0,0 +1,15 @@
<?php
class WarehouseArticleModel extends TTCrudBaseModel {
public int $id;
public string $title;
public string $description;
public string $category;
public ?float $cheapestPurchasePrice;
public ?float $cheapestSellPrice;
public int $warningAmount;
public int $criticalAmount;
public int $isEShop;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseArticleDistributor extends mfBaseModel
{
}

View File

@@ -0,0 +1,73 @@
<?php
class WarehouseArticleDistributorController extends TTCrud {
protected string $headerTitle = 'Lieferanteintrag';
protected string $createText = 'Lieferanteintrag erstellen';
// @formatter:off
protected array $columns = [
['key' => 'purchasePrice', 'text' => 'Einkaufspreis', 'required' => true, 'modal' => ['type' => 'number']],
['key' => 'articleId', 'text' => 'Artikel', 'required' => true, 'modal' => ['type' => 'select', 'data' => 'WarehouseArticle']],
['key' => 'distributorId', 'text' => 'Lieferant', 'required' => true, 'modal' => ['type' => 'select', 'data' => 'WarehouseDistributor']],
['key' => 'externalArticleNumber', 'text' => 'Externe Artikelnummer', 'required' => true],
['key' => 'note', 'text' => 'Notiz', 'required' => false, 'modal' => ['type' => 'textarea']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']]
];
// @formatter:on
protected array $infoMessages = ['create' => 'Lieferanteintrag wurde erstellt',
'update' => 'Lieferanteintrag wurde aktualisiert',
'delete' => 'Lieferanteintrag wurde gelöscht',
'noChanges' => 'Keine Änderungen',];
protected function checkExistingThresholdEntry($postData): bool {
$count = WarehouseLocationThresholdOverrideModel::count(['articleId' => $postData['articleId'],
'distributorId' => $postData['distributorId']]);
if ($count > 0) {
self::returnJson(['success' => false,
'message' => 'Es existiert bereits ein Eintrag mit dieser Artikelnummer und diesem Lieferanten.']);
return false;
}
return true;
}
protected function beforeCreate($postData): bool {
return $this->checkExistingThresholdEntry($postData);
}
protected function afterCreate($postData) {
WarehouseArticleController::updateCheapestPurchasePrice($postData['articleId']);
}
protected function beforeUpdate($postData): bool {
$existing = $this->checkExistingThresholdEntry($postData);
if (!$existing) {
return false;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function afterUpdate($postData) {
WarehouseArticleController::updateCheapestPurchasePrice($postData['articleId']);
}
protected function getHistoryAction() {
$history = WarehouseHistoryModel::getByRowId($this->request->id, $this->mod);
$history = array_map(function ($item) {
$item = (array) $item;
$item['columnHeader'] = $this->columns[array_search($item['key'], array_column($this->columns, 'key'))]['text'];
return $item;
}, $history);
self::returnJson($history);
}
}

View File

@@ -0,0 +1,10 @@
<?php
class WarehouseArticleDistributorModel extends TTCrudBaseModel {
public int $id;
public int $articleId;
public int $distributorId;
public float $purchasePrice;
public string $externalArticleNumber;
public ?string $note;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseArticlePrice extends mfBaseModel
{
}

View File

@@ -0,0 +1,81 @@
<?php
class WarehouseArticlePriceController extends TTCrud {
protected string $headerTitle = 'Artikel Verkaufspreise';
protected string $createText = 'Artikel Verkaufspreis erstellen';
// @formatter:off
protected array $columns = [
['key' => 'articleId', 'text' => 'Artikel', 'required' => true],
['key' => 'articlePriceTypeId', 'text' => 'Preistyp', 'required' => true],
['key' => 'priceMultiplier', 'text' => 'Preismultiplikator', 'required' => false],
['key' => 'priceOverride', 'text' => 'Preisüberschreibung', 'required' => false]
];
// @formatter:on
protected array $infoMessages = ['create' => 'Artikel Verkaufspreis wurde erstellt',
'update' => 'Artikel Verkaufspreis wurde aktualisiert',
'delete' => 'Artikel Verkaufspreis wurde gelöscht',
'noChanges' => 'Keine Änderungen'];
protected function beforeCreate($postData): bool {
return $this->validate($postData);
}
protected function validate($postData): bool {
// check if postData priceOverride or priceMultiplier is set but only one of them
if (isset($postData['priceOverride']) && isset($postData['priceMultiplier'])) {
self::returnJson(['success' => false,
'message' => 'Entweder Preismultiplikator oder Preisüberschreibung kann gesetzt werden.']);
return false;
} else if (!isset($postData['priceOverride']) && !isset($postData['priceMultiplier'])) {
self::returnJson(['success' => false,
'message' => 'Entweder Preismultiplikator oder Preisüberschreibung muss gesetzt werden.']);
return false;
}
if (isset($postData['id'])) {
$count = WarehouseArticlePriceModel::count(['articleId' => $postData['articleId'],
'articlePriceTypeId' => $postData['articlePriceTypeId'],
'id' => $postData['id']]);
if ($count > 0) return true;
} else {
$count = WarehouseArticlePriceModel::count(['articleId' => $postData['articleId'],
'articlePriceTypeId' => $postData['articlePriceTypeId']]);
if ($count > 0) {
self::returnJson(['success' => false,
'message' => 'Es existiert bereits ein Preis für diesen Artikel und diesen Preistyp.']);
return false;
}
}
return true;
}
protected function beforeUpdate($postData): bool {
$existing = $this->validate($postData);
if (!$existing) {
return false;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
$history = WarehouseHistoryModel::getByRowId($this->request->id, 'WarehouseArticleDistributor');
$history = array_map(function ($item) {
$item = (array) $item;
$item['columnHeader'] = $this->columns[array_search($item['key'], array_column($this->columns, 'key'))]['text'];
return $item;
}, $history);
self::returnJson($history);
}
}

View File

@@ -0,0 +1,9 @@
<?php
class WarehouseArticlePriceModel extends TTCrudBaseModel {
public int $id;
public int $articleId;
public int $articlePriceTypeId;
public ?float $priceMultiplier;
public ?float $priceOverride;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseArticlePriceType extends mfBaseModel
{
}

View File

@@ -0,0 +1,67 @@
<?php
class WarehouseArticlePriceTypeController extends TTCrud {
protected string $headerTitle = 'Artikel Verkaufspreise';
protected string $createText = 'Artikel Verkaufspreis erstellen';
// @formatter:off
protected array $columns = [
['key' => 'title', 'text' => 'Titel', 'required' => true],
['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 Verkaufspreis wurde erstellt',
'update' => 'Artikel Verkaufspreis wurde aktualisiert',
'delete' => 'Artikel Verkaufspreis wurde gelöscht',
'noChanges' => 'Keine Änderungen'];
protected function checkExistingDistributorEntry($postData): bool {
// if postData id exists check if there is already an entry with the same articleId and locationId if postdata id and WarehouseLocationThresholdOverrideModel id are different return false
if (isset($postData['id'])) {
$count = WarehouseArticlePriceTypeModel::count(['title' => $postData['title'], 'id' => $postData['id']]);
if ($count > 0) {
return true;
}
} else {
$count = WarehouseArticlePriceTypeModel::count(['title' => $postData['title']]);
if ($count > 0) {
self::returnJson(['success' => false,
'message' => 'Es existiert bereits ein Preis Typ mit diesem Titel.']);
return false;
}
}
return true;
}
protected function beforeCreate($postData): bool {
return $this->checkExistingDistributorEntry($postData);
}
protected function beforeUpdate($postData): bool {
$existing = $this->checkExistingDistributorEntry($postData);
if (!$existing) {
return false;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
$history = WarehouseHistoryModel::getByRowId($this->request->id, $this->mod);
$history = array_map(function ($item) {
$item = (array) $item;
$item['columnHeader'] = $this->columns[array_search($item['key'], array_column($this->columns, 'key'))]['text'];
return $item;
}, $history);
self::returnJson($history);
}
}

View File

@@ -0,0 +1,6 @@
<?php
class WarehouseArticlePriceTypeModel extends TTCrudBaseModel {
public int $id;
public string $title;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseDistributor extends mfBaseModel
{
}

View File

@@ -0,0 +1,45 @@
<?php
class WarehouseDistributorController extends TTCrud {
protected string $headerTitle = 'Lieferant';
protected string $createText = 'Lieferant erstellen';
// @formatter:off
protected array $columns = [
['key' => 'name', 'text' => 'Name', 'required' => true, 'table' => ['priority' => 11]],
['key' => 'address', 'text' => 'Adresse', 'required' => true],
['key' => 'plz', 'text' => 'PLZ', 'required' => true],
['key' => 'city', 'text' => 'Stadt', 'required' => true],
['key' => 'countryId', 'text' => 'Land', 'required' => true, 'modal' => ['type' => 'select', 'items' => []]],
['key' => 'email', 'text' => 'E-Mail', 'required' => true],
['key' => 'phone', 'text' => 'Telefon', 'required' => true],
['key' => 'contactPerson', 'text' => 'Kontaktperson', 'required' => true],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
];
// @formatter:on
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
protected array $infoMessages = ['create' => 'Lieferant wurde erstellt',
'update' => 'Lieferant wurde aktualisiert',
'delete' => 'Lieferant wurde gelöscht',
'noChanges' => 'Keine Änderungen',];
public function prepareCrudConfig() {
$countries = array_map(function ($country) {
return ['value' => $country->id, 'text' => $country->name];
}, CountryModel::getAll());
$this->columns[4]['modal']['items'] = $countries;
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
}

View File

@@ -0,0 +1,13 @@
<?php
class WarehouseDistributorModel extends TTCrudBaseModel {
public int $id;
public string $name;
public string $address;
public string $plz;
public string $city;
public int $countryId;
public string $email;
public string $phone;
public string $contactPerson;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseEShop extends mfBaseModel
{
}

View File

@@ -0,0 +1,41 @@
<?php
class WarehouseEShopController extends TTCrud {
protected string $headerTitle = 'Energie Steiermark Shop';
protected bool $createText = false;
protected array $columns = [
['key' => 'title', 'text' => 'Titel'],
['key' => 'category', 'text' => 'Kategorie'],
['key' => 'amount', 'text' => 'Menge', 'table' => ['filter' => false,'sortable' => false,'class' => 'p-0 width-80']],
['key' => 'add', 'text' => 'Hinzufügen', 'table' => ['filter' => false,'sortable' => false, 'class' => 'width-120 text-center']]
];
protected array $infoMessages = [
'create' => 'Not possible',
'update' => 'Not possible',
'delete' => 'Not possible',
'noChanges' => 'Keine Änderungen',
];
public function getAction() {
$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;
$filter['isEShop'] = 1;
$rows = WarehouseArticleModel::getAll($filter, $perPage, ($page - 1) * $perPage, $order);
$filteredAvailable = WarehouseArticleModel::count($filter);
$totalRows = WarehouseArticleModel::count(['isEShop' => 1]);
self::returnJson(["rows" => $rows,
"pagination" => ["page" => $page,
"total_pages" => ceil($filteredAvailable / $perPage),
"per_page" => $perPage,
"filtered_available" => intval($filteredAvailable),
"total_rows" => intval($totalRows)]]); }
}

View File

@@ -0,0 +1,7 @@
<?php
class WarehouseEShopModel extends TTCrudBaseModel {
public int $id;
public string $title;
public int $assignedTo;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseEShopOrder extends mfBaseModel
{
}

View File

@@ -0,0 +1,94 @@
<?php
class WarehouseEShopOrderController extends TTCrud {
protected string $headerTitle = 'Energie Steiermark Bestellungen';
protected bool $createText = false;
protected array $columns = [
['key' => 'id', 'text' => 'ID', 'modal' => false],
['key' => 'status', 'text' => 'Status', 'required' => true],
['key' => 'deliveryMode', 'text' => 'Liefermodus', 'required' => true, 'modal' => ['type' => 'select', 'items' => [
['value' => 'singleAddress', 'text' => 'Einzelne Adresse'],
['value' => 'multipleAddresses', 'text' => 'Mehrere Adressen'],
]]],
['key' => 'deliveryAddressName', 'text' => 'Lieferadresse Name', 'required' => true, 'table' => false],
['key' => 'deliveryAddressLine', 'text' => 'Lieferadresse', 'required' => true, 'required_length' => 4],
['key' => 'deliveryAddressPLZ', 'text' => 'Lieferadresse PLZ', 'required' => true, 'regex' => '/^\d{4}$/', 'table' => false],
['key' => 'deliveryAddressCity', 'text' => 'Lieferadresse Stadt', 'required' => true, 'required_length' => 3, 'table' => false],
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false, 'filter' => 'datetime'],
['key' => 'createBy', 'text' => 'Erstellt von', 'required' => true,'modal' => ['type' => 'select', 'items' => []]],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalActions = [
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'],
];
protected array $infoMessages = [
'create' => 'Bestellung wurde erfolgreich erstellt, sie erhalten in Kürze eine Bestätigungsmail',
'update' => 'Lagerort wurde aktualisiert',
'delete' => 'Lagerort wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
protected function prepareCrudConfig() {
$users = array_map(function($user) {
return ['value' => intval($user->id), 'text' => $user->name];
}, UserModel::search(['employee' => true]));
$this->columns[8]['modal']['items'] = $users;
}
protected function createOrderAction() {
//TODO: change this to beforeCreate and afterCreate
$json = json_decode(file_get_contents('php://input'), true);
$shoppingCart = $json['shoppingCart'];
unset($json['shoppingCart']);
$json['status'] = 'new';
$json['create'] = time();
$json['createBy'] = $this->user->id;
Helper::validateArray($json, $this->getCheckArray());
$id = WarehouseEShopOrderModel::create([
'status' => 'new',
'deliveryMode' => $json['deliveryMode'],
'deliveryAddressName' => $json['deliveryAddressName'],
'deliveryAddressLine' => $json['deliveryAddressLine'],
'deliveryAddressPLZ' => $json['deliveryAddressPLZ'],
'deliveryAddressCity' => $json['deliveryAddressCity'],
'create' => $json['create'],
'createBy' => $json['createBy'],
]);
// now create WarehouseEShopOrderItems for each item in the shopping cart
foreach ($shoppingCart as $item) {
WarehouseEShopOrderItemModel::create([
'orderId' => $id,
'articleId' => $item['itemId'],
'quantity' => intval($item['amount']),
]);
}
self::returnJson(['success' => true,
'message' => $this->infoMessages['create'],
'id' => $id]);
$json['id'] = $id;
die(json_encode($json));
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* @property int $id
* @property 'new'|'accepted'|'sent'|'done' $status
* @property 'singleAddress'|'multipleAddresses' $deliveryMode
* @property string $deliveryAddressName
* @property string $deliveryAddressLine
* @property string $deliveryAddressPLZ
* @property string $deliveryAddressCity
* @property int $create
* @property int $createBy
*/
class WarehouseEShopOrderModel extends TTCrudBaseModel {
public int $id;
public string $status;
public string $deliveryMode;
public string $deliveryAddressName;
public string $deliveryAddressLine;
public string $deliveryAddressPLZ;
public string $deliveryAddressCity;
public int $create;
public int $createBy;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseEShopOrderItem extends mfBaseModel
{
}

View File

@@ -0,0 +1,14 @@
<?php
/**
* @property int $id
* @property int $orderId
* @property int $articleId
* @property int $quantity
*/
class WarehouseEShopOrderItemModel extends TTCrudBaseModel {
public int $id;
public int $orderId;
public int $articleId;
public int $quantity;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseHistory extends mfBaseModel
{
}

View File

@@ -0,0 +1,52 @@
<?php
class WarehouseHistoryController {
public function create($postData, $mod) {
$modelClass = $mod . 'Model';
$modelClass = new $modelClass();
$currentData = $modelClass::get($postData['id']);
$me = new User();
$me->loadMe();
foreach (array_diff_assoc($postData, (array) $currentData) as $key => $value) {
WarehouseHistoryModel::create(['table' => $mod,
'row_id' => $postData['id'],
'key' => $key,
'old_value' => $currentData->$key,
'new_value' => $value,
'note' => '',
'user_id' => $me->id,
'create' => date('U')]);
}
}
public function getHistory($id, string $mod, $columns): array {
$history = WarehouseHistoryModel::getByRowId($id, $mod);
return array_map(function ($item) use ($columns) {
$item = (array) $item;
if (isset($this->columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'])) {
if($this->columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'checkbox') {
$item['old_value'] = $item['old_value'] === '1' ? 'Ja' : 'Nein';
$item['new_value'] = $item['new_value'] === '1' ? 'Ja' : 'Nein';
}
if($this->columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'select') {
$column = $this->columns[array_search($item['key'], array_column($columns, 'key'))];
$item['old_value'] = $column['modal']['items'][array_search($item['old_value'], array_column($column['modal']['items'], 'value'))]['text'];
$item['new_value'] = $column['modal']['items'][array_search($item['new_value'], array_column($column['modal']['items'], 'value'))]['text'];
}
}
$item['columnHeader'] = $columns[array_search($item['key'], array_column($columns, 'key'))]['text'];
return $item;
}, $history);
}
}

View File

@@ -0,0 +1,65 @@
<?php
//TODO: maybe convert this to use with TTCrudBaseModel
class WarehouseHistoryModel {
public int $id;
public string $table;
public int $row_id;
public string $key;
public string $old_value;
public string $new_value;
public string $note;
public int $user_id;
public string $user_name;
public int $create;
public function __construct($data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
public static function create($data) {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$sql = /** @lang text */ "INSERT INTO `WarehouseHistory` (`table`, `row_id`, `key`, `old_value`, `new_value`, `note`, `user_id`, `create`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $db->prepare($sql);
$stmt->execute([
$data["table"],
$data["row_id"],
$data["key"],
$data["old_value"],
$data["new_value"],
$data["note"],
$data["user_id"],
$data["create"]
]);
return $stmt->insert_id;
}
/**
* Retrieves an array of WarehouseHistoryModel objects by row ID.
*
* @param int $id The row ID.
* @param string $table The table name.
* @return WarehouseHistoryModel[] Array of WarehouseHistoryModel objects.
*/
public static function getByRowId(int $id, string $table): array {
$db = FronkDB::singleton();
$id = $db->escape($id);
$sql = /** @lang text */ "SELECT WH.*, W.name as user_name FROM `WarehouseHistory` WH
LEFT JOIN `Worker` W ON WH.user_id = W.id
WHERE `row_id` = $id AND `table` = '$table' ORDER BY `create` DESC";
$result = $db->query($sql);
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = new WarehouseHistoryModel($row);
}
return $rows;
}
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseItem extends mfBaseModel
{
}

View File

@@ -0,0 +1,77 @@
<?php
class WarehouseItemController extends TTCrud {
protected string $headerTitle = 'Eintrag';
protected string $createText = 'Eintrag erstellen';
// TODO: change articleId and warehouseLocationId to autocomplete
// @formatter:off
protected array $columns = [
['key' => 'articleId', 'text' => 'Artikel', 'required' => true, 'type' => 'select','table' => ['class' => 'text-nowrap'], 'modal' => ['items' => [], 'type' => 'select']],
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true, 'type' => 'select', 'modal' => ['items' => [], 'type' => 'select']],
['key' => 'quantity', 'text' => 'Menge', 'required' => true, 'type' => 'number'],
['key' => 'serialNumber', 'text' => 'Seriennummer', 'required' => false],
['key' => 'note', 'text' => 'Notiz', 'required' => false],
['key' => 'actions', 'text' => 'Aktionen', 'table' => ['filter' => false], 'required' => false, 'modal' => false]
];
// @formatter:on
protected array $additionalActions = [
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'],
];
protected array $infoMessages = [
'create' => 'Eintrag wurde erstellt',
'update' => 'Eintrag wurde aktualisiert',
'delete' => 'Eintrag wurde gelöscht',
'noChanges' => 'Keine Änderungen',
'alreadyExists' => 'Eintrag existiert bereits an diesem Lagerort.'
];
protected function checkExistingItemOnLocation($postData): bool {
$count = WarehouseItemModel::count(['articleId' => $postData['articleId'],
'warehouseLocationId' => $postData['warehouseLocationId'],
'serialNumber' => null]);
if ($count > 0) {
self::returnJson(['success' => false, 'message' => $this->infoMessages['alreadyExists']]);
return false;
}
return true;
}
protected function beforeCreate($postData): bool {
return $this->checkExistingItemOnLocation($postData);
}
protected function beforeUpdate($postData): bool {
$existing = $this->checkExistingItemOnLocation($postData);
if (!$existing) {
return false;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
public function prepareCrudConfig() {
$articles = array_map(function($article) {
return ['value' => $article->id, 'text' => $article->title];
}, WarehouseArticleModel::getAll());
$this->columns[0]['modal']['items'] = $articles;
$locations = array_map(function($location) {
return ['value' => $location->id, 'text' => $location->title];
}, WarehouseLocationModel::getAll());
$this->columns[1]['modal']['items'] = $locations;
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
}

View File

@@ -0,0 +1,10 @@
<?php
class WarehouseItemModel extends TTCrudBaseModel {
public int $id;
public int $articleId;
public int $warehouseLocationId;
public int $quantity;
public ?string $serialNumber;
public ?string $note;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseLocation extends mfBaseModel
{
}

View File

@@ -0,0 +1,42 @@
<?php
class WarehouseLocationController extends TTCrud {
protected string $headerTitle = 'Lagerorte';
protected string $createText = 'Lagerort erstellen';
protected array $columns = [
['key' => 'title', 'text' => 'Titel', 'required' => true],
['key' => 'assignedTo', 'text' => 'Zugewiesen an', 'required' => true, 'modal' => ['type' => 'select', 'items' => []]],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalActions = [
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'],
];
protected array $infoMessages = [
'create' => 'Lagerort wurde erstellt',
'update' => 'Lagerort wurde aktualisiert',
'delete' => 'Lagerort wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
public function prepareCrudConfig() {
$users = array_map(function($user) {
return ['value' => $user->id, 'text' => $user->name];
}, UserModel::search(['employee' => true]));
$this->columns[1]['modal']['items'] = $users;
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
$this->prepareCrudConfig();
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
}

View File

@@ -0,0 +1,7 @@
<?php
class WarehouseLocationModel extends TTCrudBaseModel {
public int $id;
public string $title;
public int $assignedTo;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseLocationThresholdOverride extends mfBaseModel
{
}

View File

@@ -0,0 +1,73 @@
<?php
class WarehouseLocationThresholdOverrideController extends TTCrud {
protected string $headerTitle = 'Lagerort Schwellenwert überschreiben';
protected string $createText = 'Lieferanteintrag erstellen';
// @formatter:off
protected array $columns = [
['key' => 'locationId', 'text' => 'Lagerort', 'required' => true, 'modal' => ['type' => 'select', 'data' => 'WarehouseLocation']],
['key' => 'articleId', 'text' => 'Artikel', 'required' => true, 'modal' => ['type' => 'select', 'data' => 'WarehouseArticle']],
['key' => 'warningAmount', 'text' => 'Warnmenge', 'required' => true, 'modal' => ['type' => 'number']],
['key' => 'criticalAmount', 'text' => 'Kritische Menge', 'required' => true, 'modal' => ['type' => 'number']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']]
];
// @formatter:on
protected array $infoMessages = ['create' => 'Lagerort Schwellenwert wurde erstellt',
'update' => 'Lagerort Schwellenwert wurde aktualisiert',
'delete' => 'Lagerort Schwellenwert wurde gelöscht',
'noChanges' => 'Keine Änderungen'];
protected function checkExistingDistributorEntry($postData): bool {
// if postData id exists check if there is already an entry with the same articleId and locationId if postdata id and WarehouseLocationThresholdOverrideModel id are different return false
if (isset($postData['id'])) {
$count = WarehouseLocationThresholdOverrideModel::count(['locationId' => $postData['locationId'],
'articleId' => $postData['articleId'],
'id' => $postData['id']]);
if ($count > 0) {
return true;
}
} else {
$count = WarehouseLocationThresholdOverrideModel::count(['locationId' => $postData['locationId'],
'articleId' => $postData['articleId']]);
if ($count > 0) {
self::returnJson(['success' => false,
'message' => 'Es existiert bereits ein Eintrag mit dieser Artikelnummer und diesem Lagerort.']);
return false;
}
}
return true;
}
protected function beforeCreate($postData): bool {
return $this->checkExistingDistributorEntry($postData);
}
protected function beforeUpdate($postData): bool {
$existing = $this->checkExistingDistributorEntry($postData);
if (!$existing) {
return false;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function getHistoryAction() {
$history = WarehouseHistoryModel::getByRowId($this->request->id, 'WarehouseArticleDistributor');
$history = array_map(function ($item) {
$item = (array) $item;
$item['columnHeader'] = $this->columns[array_search($item['key'], array_column($this->columns, 'key'))]['text'];
return $item;
}, $history);
self::returnJson($history);
}
}

View File

@@ -0,0 +1,9 @@
<?php
class WarehouseLocationThresholdOverrideModel extends TTCrudBaseModel {
public int $id;
public int $locationId;
public int $articleId;
public int $warningAmount;
public int $criticalAmount;
}

View File

@@ -0,0 +1,9 @@
<?php
/**
* @property mixed|null $name
*/
class WarehouseOrderRecommendation extends mfBaseModel
{
}

View File

@@ -0,0 +1,27 @@
<?php
class WarehouseOrderRecommendationController extends TTCrud {
protected string $headerTitle = 'Lagerbestellvorschläge';
protected bool $createText = false;
protected bool $onlyView = true;
protected array $columns = [
['key' => 'articleTitle', 'text' => 'Artikel', 'required' => true],
['key' => 'locationTitle', 'text' => 'Lagerort', 'required' => true, 'table' => ['filter' => false, 'sortable' => false]],
['key' => 'count', 'text' => 'Anzahl', 'required' => true, 'table' => ['filter' => false, 'sortable' => false]],
['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => false, 'sortable' => false]],
['key' => 'recommendation', 'text' => 'Empfehlung', 'required' => true, 'table' => ['filter' => false, 'sortable' => false]],
// ['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalActions = [
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'],
];
protected array $infoMessages = [
'create' => 'Lagerort wurde erstellt',
'update' => 'Lagerort wurde aktualisiert',
'delete' => 'Lagerort wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
}

View File

@@ -0,0 +1,171 @@
<?php
/**
* Class WarehouseOrderRecommendationModel
*
* This class represents a recommendation model for warehouse orders based on the critical and warning amounts of articles in the warehouse.
*
* @property int $articleId The ID of the article.
* @property string $articleTitle The title of the article.
* @property int $count The current count of items for the article.
* @property string $status The status of the article's inventory ("Kritisch" or "Warnung").
* @property string $recommendation The recommendation message for ordering.
*/
class WarehouseOrderRecommendationModel {
public int $articleId;
public ?int $locationId;
public string $locationTitle;
public string $articleTitle;
public int $count;
public string $status;
public string $recommendation;
/**
* WarehouseOrderRecommendationModel constructor.
*
* Initializes a new instance of the WarehouseOrderRecommendationModel class.
*
* @param array $data An associative array of property values to initialize the model.
*/
public function __construct(array $data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
/**
* Retrieves recommendations based on the provided filter.
*
* @param array $filter An associative array of filter options.
* @return array An array of WarehouseOrderRecommendationModel instances.
*/
private static function customGet(array $filter = []): array {
error_reporting(E_ALL);
ini_set('display_errors', 1);
$filterOptions = [];
// Convert filter to filterOptions because the filter is not directly usable
if (isset($filter['articleTitle'])) {
$filterOptions['title'] = $filter['articleTitle'];
}
$articles = [];
foreach (WarehouseArticleModel::getAll($filterOptions) as $article) {
$articles[$article->id] = $article;
}
$items = WarehouseItemModel::getAll();
$recommendations = [];
// Check if the amount of items is below the critical or warning amount
foreach ($articles as $article) {
$items = array_filter($items, function ($item) use ($article) {
return $item->articleId === $article->id;
});
$count = array_reduce($items, function ($carry, $item) {
return $carry + $item->quantity;
}, 0);
if ($count <= $article->criticalAmount) {
$minOrderAmount = $article->criticalAmount - $count;
$recommendedOrderAmount = $article->warningAmount - $count;
$recommendations[] = new WarehouseOrderRecommendationModel(["articleId" => $article->id,
"articleTitle" => $article->title,
"count" => $count,
"status" => "Kritisch",
"recommendation" => "Bestellen, min. $minOrderAmount, empfohlen: $recommendedOrderAmount"]);
} else if ($count <= $article->warningAmount) {
$minOrderAmount = $article->warningAmount - $count;
$recommendations[] = new WarehouseOrderRecommendationModel(["articleId" => $article->id,
"articleTitle" => $article->title,
"count" => $count,
"status" => "Warnung",
"recommendation" => "Bestellen, min. $minOrderAmount"]);
}
}
$locations = [];
foreach (WarehouseLocationModel::getAll() as $location) {
$locations[$location->id] = $location;
}
$locationThresholdOverrides = WarehouseLocationThresholdOverrideModel::getAll();
foreach ($locationThresholdOverrides as $locationThresholdOverride) {
if (!isset($articles[$locationThresholdOverride->articleId])) {
continue;
}
$items = array_filter($items, function ($item) use ($locationThresholdOverride) {
return $item->articleId === $locationThresholdOverride->articleId;
});
$count = array_reduce($items, function ($carry, $item) {
return $carry + $item->quantity;
}, 0);
if ($count <= $locationThresholdOverride->criticalAmount) {
$minOrderAmount = $locationThresholdOverride->criticalAmount - $count;
$recommendedOrderAmount = $locationThresholdOverride->warningAmount - $count;
$recommendations[] = new WarehouseOrderRecommendationModel(["articleId" => $locationThresholdOverride->articleId,
"articleTitle" => $articles[$locationThresholdOverride->articleId]->title,
"locationId" => $locationThresholdOverride->locationId,
"locationTitle" => $locations[$locationThresholdOverride->locationId]->title,
"count" => $count,
"status" => "Kritisch",
"recommendation" => "Bestellen, min. $minOrderAmount, empfohlen: $recommendedOrderAmount"]);
} else if ($count <= $locationThresholdOverride->warningAmount) {
$minOrderAmount = $locationThresholdOverride->warningAmount - $count;
$recommendations[] = new WarehouseOrderRecommendationModel(["articleId" => $locationThresholdOverride->articleId,
"articleTitle" => $articles[$locationThresholdOverride->articleId]->title,
"locationId" => $locationThresholdOverride->locationId,
"locationTitle" => $locations[$locationThresholdOverride->locationId]->title,
"count" => $count,
"status" => "Warnung",
"recommendation" => "Bestellen, min. $minOrderAmount"]);
}
}
return $recommendations;
}
/**
* Counts the number of recommendations based on the provided filter.
*
* @param array $filter An associative array of filter options.
* @return int The number of recommendations.
*/
public static function count(array $filter = []): int {
return count(self::customGet($filter));
}
/**
* Retrieves all recommendations based on the provided filter, limit, offset, and order.
*
* @param array $filter An associative array of filter options.
* @param int|null $limit The maximum number of recommendations to return.
* @param int $offset The number of recommendations to skip.
* @param array $order An associative array containing the key to sort by and the order direction ('ASC' or 'DESC').
* @return array An array of WarehouseOrderRecommendationModel instances.
*/
public static function getAll(array $filter = [], int $limit = null, int $offset = 0, array $order = ["key" => null]): array {
$recommendations = self::customGet($filter);
// apply limit,offset and order
if ($order['key'] !== null) {
usort($recommendations, function ($a, $b) use ($order) {
return $order['order'] === 'ASC' ? $a->{$order['key']} <=> $b->{$order['key']} : $b->{$order['key']} <=> $a->{$order['key']};
});
}
if ($limit !== null) {
return array_slice($recommendations, $offset, $limit);
}
return $recommendations;
}
}

View File

@@ -0,0 +1,161 @@
<?php /** @noinspection ALL */
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddWarehouseTables extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
// WarehouseArticle Table
$warehouseArticleTable = $this->table("WarehouseArticle", ["signed" => true]);
$warehouseArticleTable->addColumn("title", "string", ["null" => false, "limit" => 255]);
$warehouseArticleTable->addColumn("description", "text", ["null" => false]);
$warehouseArticleTable->addColumn("category", "string", ["null" => false, "limit" => 255]);
$warehouseArticleTable->addColumn("cheapestPurchasePrice", "float", ["null" => true]);
$warehouseArticleTable->addColumn("cheapestSellPrice", "float", ["null" => true]);
$warehouseArticleTable->addColumn("warningAmount", "integer", ["null" => false]);
$warehouseArticleTable->addColumn("criticalAmount", "integer", ["null" => false]);
$warehouseArticleTable->addColumn("isEShop", "integer", ["null" => false, "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY]);
$warehouseArticleTable->addIndex(["category"]);
$warehouseArticleTable->addIndex(["isEShop"]);
$warehouseArticleTable->save();
// WarehouseArticle Table End
// WarehouseArticleDistributor Table
$warehouseArticleDistributorTable = $this->table("WarehouseArticleDistributor", ["signed" => true]);
$warehouseArticleDistributorTable->addColumn("articleId", "integer", ["null" => false]);
$warehouseArticleDistributorTable->addColumn("distributorId", "integer", ["null" => false]);
$warehouseArticleDistributorTable->addColumn("purchasePrice", "float", ["null" => false]);
$warehouseArticleDistributorTable->addColumn("externalArticleNumber", "string", ["null" => false, "limit" => 255]);
$warehouseArticleDistributorTable->addColumn("note", "string", ["null" => true, "limit" => 255]);
$warehouseArticleDistributorTable->addIndex(["articleId"]);
$warehouseArticleDistributorTable->addIndex(["distributorId"]);
$warehouseArticleDistributorTable->save();
// WarehouseArticleDistributor Table End
// WarehouseArticlePrice Table
$warehouseArticlePriceTable = $this->table("WarehouseArticlePrice", ["signed" => true]);
$warehouseArticlePriceTable->addColumn("articleId", "integer", ["null" => false]);
$warehouseArticlePriceTable->addColumn("articlePriceTypeId", "integer", ["null" => false]);
$warehouseArticlePriceTable->addColumn("priceMultiplier", "float", ["null" => true]);
$warehouseArticlePriceTable->addColumn("priceOverride", "float", ["null" => true]);
$warehouseArticlePriceTable->addIndex(["articleId"]);
$warehouseArticlePriceTable->addIndex(["articlePriceTypeId"]);
$warehouseArticlePriceTable->save();
// WarehouseArticlePrice Table End
// WarehouseArticlePriceType Table
$warehouseArticlePriceTypeTable = $this->table("WarehouseArticlePriceType", ["signed" => true]);
$warehouseArticlePriceTypeTable->addColumn("title", "string", ["null" => false, "limit" => 255]);
$warehouseArticlePriceTypeTable->save();
// WarehouseArticlePriceType Table End
// WarehouseDistributor Table
$warehouseDistributorTable = $this->table("WarehouseDistributor", ["signed" => true]);
$warehouseDistributorTable->addColumn("name", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->addColumn("address", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->addColumn("plz", "string", ["null" => false, "limit" => 16]);
$warehouseDistributorTable->addColumn("city", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->addColumn("countryId", "integer", ["null" => false]);
$warehouseDistributorTable->addColumn("email", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->addColumn("phone", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->addColumn("contactPerson", "string", ["null" => false, "limit" => 255]);
$warehouseDistributorTable->save();
// WarehouseDistributor Table End
// WarehouseEShopOrder Table
$warehouseEShopOrderTable = $this->table("WarehouseEShopOrder", ["signed" => true]);
$warehouseEShopOrderTable->addColumn("status", "enum", ["values" => ["new", "accepted", "sent", "done"], "null" => false]);
$warehouseEShopOrderTable->addColumn("deliveryMode", "enum", ["values" => ["singleAddress", "multipleAddresses"], "null" => false]);
$warehouseEShopOrderTable->addColumn("deliveryAddressName", "string", ["null" => false, "limit" => 255]);
$warehouseEShopOrderTable->addColumn("deliveryAddressLine", "string", ["null" => false, "limit" => 255]);
$warehouseEShopOrderTable->addColumn("deliveryAddressPLZ", "string", ["null" => false, "limit" => 255]);
$warehouseEShopOrderTable->addColumn("deliveryAddressCity", "string", ["null" => false, "limit" => 255]);
$warehouseEShopOrderTable->addColumn("create", "integer", ["null" => false]);
$warehouseEShopOrderTable->addColumn("createBy", "integer", ["null" => false]);
$warehouseEShopOrderTable->save();
// WarehouseEShopOrder Table End
// WarehouseEShopOrderItem Table
$warehouseEShopOrderItemTable = $this->table("WarehouseEShopOrderItem", ["signed" => true]);
$warehouseEShopOrderItemTable->addColumn("orderId", "integer", ["null" => false]);
$warehouseEShopOrderItemTable->addColumn("articleId", "integer", ["null" => false]);
$warehouseEShopOrderItemTable->addColumn("quantity", "integer", ["null" => false]);
$warehouseEShopOrderItemTable->addIndex(["orderId"]);
$warehouseEShopOrderItemTable->save();
// WarehouseEShopOrderItem Table End
// WarehouseHistory Table
$warehouseHistoryTable = $this->table("WarehouseHistory", ["signed" => true]);
$warehouseHistoryTable->addColumn("table", "string", ["null" => false, "limit" => 255]);
$warehouseHistoryTable->addColumn("row_id", "integer", ["null" => false]);
$warehouseHistoryTable->addColumn("key", "string", ["null" => false, "limit" => 255]);
$warehouseHistoryTable->addColumn("old_value", "string", ["null" => true, "limit" => 255]);
$warehouseHistoryTable->addColumn("new_value", "string", ["null" => true, "limit" => 255]);
$warehouseHistoryTable->addColumn("note", "string", ["null" => true, "limit" => 255]);
$warehouseHistoryTable->addColumn("user_id", "integer", ["null" => false]);
$warehouseHistoryTable->addColumn("create", "integer", ["null" => false]);
$warehouseHistoryTable->addIndex(["table"]);
$warehouseHistoryTable->addIndex(["row_id"]);
$warehouseHistoryTable->save();
// WarehouseHistory Table End
// WarehouseItem Table
$warehouseItemTable = $this->table("WarehouseItem", ["signed" => true]);
$warehouseItemTable->addColumn("articleId", "integer", ["null" => false]);
$warehouseItemTable->addColumn("warehouseLocationId", "integer", ["null" => false]);
$warehouseItemTable->addColumn("quantity", "integer", ["null" => false]);
$warehouseItemTable->addColumn("serialNumber", "string", ["null" => true, "limit" => 255]);
$warehouseItemTable->addColumn("note", "string", ["null" => true, "limit" => 255]);
$warehouseItemTable->addIndex(["articleId"]);
$warehouseItemTable->addIndex(["warehouseLocationId"]);
$warehouseItemTable->save();
// WarehouseItem Table End
// WarehouseLocation Table
$warehouseLocationTable = $this->table("WarehouseLocation", ["signed" => true]);
$warehouseLocationTable->addColumn("title", "string", ["null" => false, "limit" => 255]);
$warehouseLocationTable->addColumn("assignedTo", "integer", ["null" => false]);
$warehouseLocationTable->addIndex(["assignedTo"]);
$warehouseLocationTable->save();
// WarehouseLocation Table End
// WarehouseLocationThresholdOverride Table
$warehouseLocationThresholdOverrideTable = $this->table("WarehouseLocationThresholdOverride", ["signed" => true]);
$warehouseLocationThresholdOverrideTable->addColumn("locationId", "integer", ["null" => false]);
$warehouseLocationThresholdOverrideTable->addColumn("articleId", "integer", ["null" => false]);
$warehouseLocationThresholdOverrideTable->addColumn("warningAmount", "integer", ["null" => false]);
$warehouseLocationThresholdOverrideTable->addColumn("criticalAmount", "integer", ["null" => false]);
$warehouseLocationThresholdOverrideTable->addIndex(["locationId"]);
$warehouseLocationThresholdOverrideTable->addIndex(["articleId"]);
$warehouseLocationThresholdOverrideTable->save();
// WarehouseLocationThresholdOverride Table End
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$this->table("WarehouseArticle")->drop()->save();
$this->table("WarehouseArticleDistributor")->drop()->save();
$this->table("WarehouseArticlePrice")->drop()->save();
$this->table("WarehouseArticlePriceType")->drop()->save();
$this->table("WarehouseDistributor")->drop()->save();
$this->table("WarehouseEShopOrder")->drop()->save();
$this->table("WarehouseEShopOrderItem")->drop()->save();
$this->table("WarehouseHistory")->drop()->save();
$this->table("WarehouseItem")->drop()->save();
$this->table("WarehouseLocation")->drop()->save();
$this->table("WarehouseLocationThresholdOverride")->drop()->save();
}
}
}

View File

@@ -4,10 +4,9 @@ class Helper {
/**
* Generate SQL Filter condition (space separated) for a given column.
*
* @param string|null $filterValue The filter value to match against.
* @param string|null|array $filterValue The filter value to match against.
* @param string $columnName The name of the column in the database table.
* @return string The SQL condition generated based on the filter value and column name.
* @noinspection PhpMissingParamTypeInspection
*/
public static function generateFilterCondition($filterValue, string $columnName, bool $exactMatch = false): string {
$sql = "";
@@ -19,6 +18,8 @@ class Helper {
$sql = " AND `$columnName` >= " . $filterValue['from'];
} elseif (isset($filterValue['to'])) {
$sql = " AND `$columnName` <= " . $filterValue['to'];
} else if (isset($filterValue['exact'])) {
$sql = " AND `$columnName` = " . "'{$filterValue['exact']}'";
}
} else if ($filterValue === "0" || $filterValue === "1") {
$sql .= " AND `$columnName` = " . $filterValue;
@@ -37,4 +38,95 @@ class Helper {
return $sql;
}
/**
* Validates an array of data based on a set of predefined rules.
*
* @param array $data The data to validate. Keys represent field names, and values are the corresponding data.
* @param array $checkArray An associative array defining validation rules for each field:
* - key: The field name to validate.
* - value: An associative array of validation rules for that field:
* - required (bool, optional): Whether the field is required. Default: false.
* - title (string, optional): The human-readable name of the field to use in error messages.
* - required_length (int, optional): The minimum required length of the value. Default: 1.
* - regex (string, optional): A regular expression pattern the value must match.
*
* @return array|true Returns `true` if validation passes for all fields. Otherwise, returns an associative array of errors
* where keys are field names, and values are error messages.
*/
public static function validateArray(array $data, array $checkArray, bool $printErrors = true) {
$errors = [];
foreach ($checkArray as $key => $rules) {
$value = $data[$key] ?? null;
$title = $rules['title'] ?? $key;
// Apply default values for missing rules
$rules = array_merge([
'required' => false,
'required_length' => 1,
'regex' => false,
], $rules);
// Required Check
if ($rules['required'] && (is_null($value) || $value === '')) {
$errors[$key] = "$title wird benötigt.";
}
// Length Check (only if value exists)
if (!is_null($value) && strlen($value) < $rules['required_length']) {
$errors[$key] = "$title muss mindestens $rules[required_length] Zeichen lang sein.";
}
// Regex Check (only if value exists and regex is provided)
if (!is_null($value) && $rules['regex'] && !preg_match($rules['regex'], $value)) {
$errors[$key] = "$title hat ein ungültiges Format.";
}
}
if ($printErrors) {
if (!empty($errors)) {
header('Content-Type: application/json');
die(json_encode(
[
'success' => false,
'errors' => $errors
]
));
}
}
return empty($errors) ? true : $errors;
}
/**
* Displays Vue component with the given header title.
*
* @param mfBaseController $controller The controller instance to generate $JSGlobals for.
* @param string $pageName The name of the Vue component to render.
* @param string $headerTitle The title to display in the header.
* @param array $additionalGlobals Additional global variables to pass to the Vue component.
*/
public static function renderVue(mfBaseController $controller, string $pageName, string $headerTitle, array $additionalGlobals = []) {
$JSGlobals = ["BASE_URL" => $controller::getUrl($pageName),
"MF_URL" => $controller::getUrl(""),
"DASHBOARD_URL" => $controller::getUrl("Dashboard"),
"MF_APP_NAME" => MFAPPNAME_SLUG,
"BASE_PATH" => $controller::getUrl(""),
"PAGE_TITLE" => $headerTitle,
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => $controller::getUrl("Dashboard")],
["text" => $headerTitle, "href" => $controller::getUrl($pageName)]
],
];
$JSGlobals = array_merge($JSGlobals, $additionalGlobals);
$controller->layout()->set("vueViewName", $pageName);
$controller->layout()->set("JSGlobals", $JSGlobals);
$controller->layout()->setTemplate("VueViews/Vue");
}
}

178
lib/TTCrud/TTCrud.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
/**
* Class TTCrud
* @property string $headerTitle
* @property string $createText
* @property array $columns
* @property array $additionalActions
* @property array $infoMessages
* @property bool $onlyView
*/
class TTCrud extends mfBaseController {
public User $user;
private array $checkArray;
public ?array $postData;
/** @noinspection PhpMissingFieldTypeInspection */
public $model;
protected function init() {
$this->needlogin = true;
$me = new User();
$me->loadMe();
$this->user = $me;
$this->layout()->set('me', $me);
if (!$me->is(["Admin"])) {
$this->redirect("Dashboard");
}
$modelName = $this->mod . 'Model';
$this->model = new $modelName();
$this->postData = json_decode(file_get_contents('php://input'), true);
$this->checkArray = $this->getCheckArray();
}
/**
* Returns the checkArray for the CRUD component.
* @return array
*/
protected function getCheckArray(): array {
$checkArray = [];
foreach ($this->columns as $column) {
$checkArray[$column['key']] = ['required' => $column['required'] ?? false,
'required_length' => $column['required_length'] ?? 0,
'title' => $column['text'] ?? $column['key'],
'regex' => $column['regex'] ?? false];
}
return $checkArray;
}
protected function indexAction() {
$this->layout()->set('additionalJS', ['js/pages/WarehouseHistory/WarehouseHistoryModal.js']);
$customJsFile = defined('BASEDIR') ? BASEDIR . "/public/js/pages/{$this->mod}/{$this->mod}.js" : null;
if ($customJsFile && file_exists($customJsFile)) {
$pageName = $this->mod;
} else {
$pageName = "DefaultCrudView";
}
Helper::renderVue($this, $pageName, $this->headerTitle, ["CRUD_CONFIG" => $this->getCrudConfig(),
"CREATE_URL" => $this::getUrl($this->mod . "/create"),
"TABLE_URL" => $this::getUrl($this->mod . "/get"),
"UPDATE_URL" => $this::getUrl($this->mod . "/update"),
"DELETE_URL" => $this::getUrl($this->mod . "/delete"),]);
}
/**
* Returns the configuration for the CRUD component for the Vue component.
* @return array
*/
protected function getCrudConfig(): array {
if (method_exists($this, 'prepareCrudConfig')) {
$this->prepareCrudConfig();
}
$columns = array_map(function ($column) {
$newColumn = $column;
unset($newColumn['required'], $newColumn['required_length'], $newColumn['regex']);
return $newColumn;
}, $this->columns);
return ['key' => $this->mod,
'tableHeader' => $this->headerTitle,
'createText' => $this->createText,
'columns' => $columns,
'additionalActions' => $this->additionalActions];
}
protected function getAction() {
$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;
$rows = $this->model::getAll($filter, $perPage, ($page - 1) * $perPage, $order);
$filteredAvailable = $this->model::count($filter);
$totalRows = $this->model::count();
self::returnJson(["rows" => $rows,
"pagination" => ["page" => $page,
"total_pages" => ceil($filteredAvailable / $perPage),
"per_page" => $perPage,
"filtered_available" => intval($filteredAvailable),
"total_rows" => intval($totalRows)]]);
}
protected function createAction() {
Helper::validateArray($this->postData, $this->checkArray);
if (method_exists($this, 'beforeCreate') && !$this->beforeCreate($this->postData)) {
self::returnJson(['success' => false, 'message' => 'Ein Fehler ist aufgetreten.']);
}
$id = $this->model::create($this->postData);
if (method_exists($this, 'afterCreate')) {
$this->afterCreate($this->postData);
}
self::returnJson(['success' => true,
'message' => $this->infoMessages['create'],
'id' => $id]);
}
protected function updateAction() {
Helper::validateArray($this->postData, array_merge($this->checkArray, ['id' => ['required' => true]]));
if (method_exists($this, 'beforeUpdate') && !$this->beforeUpdate($this->postData)) {
self::returnJson(['success' => false, 'message' => 'Ein Fehler ist aufgetreten.']);
}
$affectedRows = $this->model::update($this->postData);
if (method_exists($this, 'afterUpdate')) {
$this->afterUpdate($this->postData);
}
self::returnJson(['success' => $affectedRows > 0,
'message' => $affectedRows > 0 ? $this->infoMessages['update'] : $this->infoMessages['noChanges']]);
}
protected function deleteAction() {
Helper::validateArray($this->postData, ['id' => ['required' => true]]);
if (method_exists($this, 'beforeDelete') && !$this->beforeDelete($this->postData)) {
self::returnJson(['success' => false, 'message' => 'Ein Fehler ist aufgetreten.']);
}
$affectedRows = $this->model::delete($this->postData['id']);
self::returnJson(['success' => $affectedRows > 0,
'message' => $affectedRows > 0 ? $this->infoMessages['delete'] : $this->infoMessages['noChanges']]);
}
protected function autocompleteAction() {
$searchedID = $this->request->searchedID;
$textKey = property_exists($this->model, 'name') ? 'name' : 'title';
if (strlen($searchedID) > 0) {
$filter = ['id' => $searchedID];
} else {
$filter = [$textKey => $this->request->q];
}
$data = $this->model::getAll($filter, 10);
self::returnJson(array_map(function ($item) use ($textKey) {
return ['value' => $item->id, 'text' => $item->$textKey];
}, $data));
}
}
?>

View File

@@ -0,0 +1,170 @@
<?php
class TTCrudBaseModel {
public function __construct($data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
public static function create($data) {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$table = self::getTable();
self::checkAllFields($data, ['id']);
$sqlColumns = [];
$sqlValues = [];
foreach ($data as $field => $value) {
if (!property_exists(get_called_class(), $field)) {
throw new Exception("Field $field does not exist in " . get_called_class());
}
$sqlValues[] = $value === null ? 'NULL' : "'" . $db->real_escape_string($value) . "'";
$sqlColumns[] = "`$field`";
}
$sql = "INSERT INTO `$table` (" . implode(", ", $sqlColumns) . ") VALUES (" . implode(", ", $sqlValues) . ")";
$db->query($sql) or die($db->error);
return $db->insert_id;
}
public static function getTable(): string {
return str_replace('Model', '', get_called_class());
}
/**
* Checks if all required fields of the current class are present in a given data array.
*
* This method uses reflection to determine which fields are required based on their type declarations.
* It then iterates over these required fields and throws an exception if any of them are missing
* from the provided data array.
*
* @param array $data The data array to check.
* @param array $skip An optional array of field names to skip during the check.
*
* @return void
* @throws Exception If any required field is missing in the `$data` array.
*
*/
public static function checkAllFields(array $data, array $skip = []) {
$requiredVars = array_filter(get_class_vars(get_called_class()), function ($value, $key) {
$reflectionProperty = new ReflectionProperty(get_called_class(), $key);
return !$reflectionProperty->hasType() || !$reflectionProperty->getType()->allowsNull();
}, ARRAY_FILTER_USE_BOTH);
foreach ($requiredVars as $field => $value) {
if (in_array($field, $skip)) {
continue;
}
if (!isset($data[$field])) {
throw new Exception("Required field $field is missing in data array");
}
}
}
public static function get($id): TTCrudBaseModel {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$id = $db->real_escape_string($id);
$table = self::getTable();
$sql = "SELECT * FROM `$table` WHERE `id` = $id";
$result = $db->query($sql);
// as TTCRudBaseModel is abstract, we need to get the class name of the child class
$class = get_called_class();
return new $class($result->fetch_assoc());
}
public static function count($filter = []): int {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$table = self::getTable();
$filter = self::getSQLFilter($filter);
$sql = "SELECT COUNT(*) as count FROM `$table` $filter";
$result = $db->query($sql);
return $result->fetch_assoc()['count'];
}
public static function getSQLFilter($filter): string {
if (empty($filter)) {
return "";
}
$sql = "WHERE 1=1";
foreach ($filter as $key => $value) {
if (!property_exists(get_called_class(), $key)) {
throw new Exception("Field $key does not exist in " . get_called_class());
}
$sql .= Helper::generateFilterCondition($value, $key, gettype($value) === "integer");
}
return $sql;
}
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$table = self::getTable();
$filter = self::getSQLFilter($filter);
$sql = "SELECT * FROM `$table` $filter";
$sql .= $order['key'] === null ? " ORDER BY `id` ASC" : " ORDER BY `" . $order['key'] . "` " . $order['order'];
$sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset;
try {
$result = $db->query($sql);
} catch (Exception $e) {
echo $sql;
die($e->getMessage());
}
$rows = [];
$class = get_called_class();
while ($row = $result->fetch_assoc()) {
$rows[] = new $class($row);
}
return $rows;
}
public static function update($data) {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$table = self::getTable();
// Check if all fields are set
self::checkAllFields($data);
$values = [];
foreach ($data as $field => $value) {
if (!property_exists(get_called_class(), $field)) {
throw new Exception("Field $field does not exist in " . get_called_class());
}
if ($field === "id") {
continue;
}
$values[] = $value === null ? "`$field` = NULL" : "`$field` = '" . $db->real_escape_string($value) . "'";
}
$sql = "UPDATE `$table` SET " . implode(", ", $values) . " WHERE `id` = " . $db->real_escape_string($data['id']);
$db->query($sql);
return $db->affected_rows;
}
public static function delete($id) {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$table = self::getTable();
$id = $db->real_escape_string($id);
$sql = "DELETE FROM `$table` WHERE `id` = $id";
$db->query($sql);
return $db->affected_rows;
}
}

View File

@@ -27,19 +27,23 @@ function combineAndMinifyJS($files) {
$jsFiles = [
"plugins/axios/axios.min.js",
"plugins/axios/axios.inject.js",
"plugins/moment/moment.min.js",
"plugins/daterangepicker/daterangepicker.js",
"plugins/vue/" . (isset($_GET['VUE_DEBUG']) || $_SERVER['HTTP_HOST'] === "localhost" ? "vue.js" : "vue.min.js"),
"plugins/vue/tt-components/tt-card.js",
"plugins/vue/tt-components/tt-table.js",
"plugins/vue/tt-components/tt-table-crud.js",
"plugins/vue/tt-components/tt-page-title.js",
"plugins/vue/tt-components/tt-loader.js",
"plugins/vue/tt-components/tt-select.js",
"plugins/vue/tt-components/tt-datepicker.js",
"plugins/vue/tt-components/tt-input.js",
"plugins/vue/tt-components/tt-modal.js",
"plugins/vue/tt-components/tt-autocomplete.js",
"plugins/vue/tt-components/tt-icon-select.js",
"plugins/vue/tt-components/tt-number-range.js",
"plugins/vue/tt-components/tt-checkbox.js",
];

View File

@@ -0,0 +1,13 @@
Vue.component('default-crud-view', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,287 @@
Vue.component('warehouse-distributor-modal', {
//language=Vue
template: `
<tt-modal :show.sync="showModal" title="Lieferanten" :delete="false" :save="false"
@close="$emit('update:show', false)">
<div v-for="(row, index) in rows" :key="index" style="display:grid;grid-template-columns: auto auto">
<div style="display:grid; grid-template-columns: auto auto auto">
<tt-autocomplete v-model="row.distributorId"
:api-url="window['TT_CONFIG']['BASE_PATH'] + '/WarehouseDistributor/autocomplete'"></tt-autocomplete>
<tt-input v-model="row.purchasePrice" placeholder="Price" type="number" sm></tt-input>
<tt-input v-model="row.externalArticleNumber" placeholder="External Article Number" sm></tt-input>
</div>
<div class="btn-group mb-4">
<a class="btn btn-sm btn-primary" href="#" @click="saveRow(index)" title="Save Row">
<i class="fas fa-check"></i>
</a>
<a class="btn btn-sm btn-danger" href="#" @click="deleteRow(index)" title="Delete Row">
<i class="fas fa-trash"></i>
</a>
</div>
</div>
<a href="#" @click="addRow"><i class="fas fa-plus"></i>Lieferant hinzufügen</a>
</tt-modal>
`, props: {
show: {type: Boolean, default: false}, id: {type: Number, default: null},
}, data() {
return {
window: window, showModal: false, rows: []
};
}, async mounted() {
}, methods: {
async fetchArticleDistributor() {
const response = await axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseArticleDistributor/get', {filters: {articleId: this.id}});
this.rows = response.data.rows
}, addRow() {
this.rows.push({distributor: null, price: null, externalArticleNumber: null});
}, async saveRow(index) {
// post to /WarehouseArticleDistributor/save with rows data and articleId
const row = this.rows[index];
const data = {
articleId: this.id, distributorId: row.distributorId, purchasePrice: row.purchasePrice, externalArticleNumber: row.externalArticleNumber
}
if (row.id) {
data.id = row.id;
}
const response = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${row.id ? 'update' : 'create'}`, data);
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchArticleDistributor();
}, async deleteRow(index) {
const row = this.rows[index];
if (!row.id) {
this.window.notify('error', 'Eintrag hat keine ID');
}
const response = axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseArticleDistributor/delete', {id: row.id});
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchArticleDistributor();
}
}, watch: {
show(newVal) {
this.showModal = newVal;
if (!newVal) {
this.rows = [];
return;
}
this.rows = [];
this.fetchArticleDistributor().then();
}
}
});
Vue.component('warehouse-threshold-modal', {
//language=Vue
template: `
<tt-modal :show.sync="showModal" title="Schwellenwerte" :delete="false" :save="false" @close="$emit('update:show', false)">
<div v-for="(row, index) in rows" :key="index" style="display:grid;grid-template-columns: auto auto">
<div style="display:grid; grid-template-columns: auto auto auto">
<tt-autocomplete v-model="row.locationId"
:api-url="window['TT_CONFIG']['BASE_PATH'] + '/WarehouseLocation/autocomplete'"></tt-autocomplete>
<tt-input v-model="row.warningAmount" placeholder="Warnmenge" type="number" sm></tt-input>
<tt-input v-model="row.criticalAmount" placeholder="Kritische Menge" type="number" sm></tt-input>
</div>
<div class="btn-group mb-4">
<a class="btn btn-sm btn-primary" href="#" @click="saveRow(index)" title="Save Row">
<i class="fas fa-check"></i>
</a>
<a class="btn btn-sm btn-danger" href="#" @click="deleteRow(index)" title="Delete Row">
<i class="fas fa-trash"></i>
</a>
</div>
</div>
<a href="#" @click="addRow"><i class="fas fa-plus"></i>Schwellenwert hinzufügen</a>
</tt-modal>
`, props: {
show: {type: Boolean, default: false}, id: {type: Number, default: null},
}, data() {
return {
window: window, showModal: false, rows: []
};
}, async mounted() {
}, methods: {
async fetchLocationThreshold() {
const response = await axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseLocationThresholdOverride/get', {filters: {articleId: this.id}});
this.rows = response.data.rows
}, addRow() {
this.rows.push({distributor: null, price: null, externalArticleNumber: null});
}, async saveRow(index) {
// post to /WarehouseArticleDistributor/save with rows data and articleId
const row = this.rows[index];
const data = {
articleId: this.id, locationId: row.locationId, warningAmount: row.warningAmount, criticalAmount: row.criticalAmount
}
if (row.id) {
data.id = row.id;
}
const response = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseLocationThresholdOverride/${row.id ? 'update' : 'create'}`, data);
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchLocationThreshold()
}, async deleteRow(index) {
const row = this.rows[index];
if (!row.id) {
this.window.notify('error', 'Eintrag hat keine ID');
}
const response = axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseLocationThresholdOverride/delete', {id: row.id});
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchLocationThreshold();
}
}, watch: {
show(newVal) {
this.showModal = newVal;
if (!newVal) {
this.rows = [];
return;
}
this.rows = [];
this.fetchLocationThreshold().then();
}
}
});
Vue.component('warehouse-article-price-modal', {
//language=Vue
template: `
<tt-modal :show.sync="showModal" title="Artikel-Preise" :delete="false" :save="false" @close="$emit('update:show', false)">
<div v-for="(row, index) in rows" :key="index" style="display:grid;grid-template-columns: auto auto">
<div style="display:grid; grid-template-columns: auto auto auto">
<tt-autocomplete v-model="row.articlePriceTypeId"
:api-url="window['TT_CONFIG']['BASE_PATH'] + '/WarehouseArticlePriceType/autocomplete'"></tt-autocomplete>
<tt-input v-model="row.priceMultiplier" placeholder="Preis Multiplikator" type="number" sm></tt-input>
<tt-input v-model="row.priceOverride" placeholder="Preis" type="number" sm></tt-input>
</div>
<div class="btn-group mb-4">
<a class="btn btn-sm btn-primary" href="#" @click="saveRow(index)" title="Save Row">
<i class="fas fa-check"></i>
</a>
<a class="btn btn-sm btn-danger" href="#" @click="deleteRow(index)" title="Delete Row">
<i class="fas fa-trash"></i>
</a>
</div>
</div>
<a href="#" @click="addRow"><i class="fas fa-plus"></i>Preis hinzufügen</a>
</tt-modal>
`, props: {
show: {type: Boolean, default: false}, id: {type: Number, default: null},
}, data() {
return {
window: window, showModal: false, rows: []
};
}, async mounted() {
}, methods: {
async fetchLocationThreshold() {
const response = await axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseArticlePrice/get', {filters: {articleId: this.id}});
this.rows = response.data.rows
}, addRow() {
this.rows.push({articlePriceTypeId: null, priceMultiplier: null, priceOverride: null});
}, async saveRow(index) {
// post to /WarehouseArticleDistributor/save with rows data and articleId
const row = this.rows[index];
const data = {
articleId: this.id, articlePriceTypeId: row.articlePriceTypeId, priceMultiplier: row.priceMultiplier, priceOverride: row.priceOverride
}
if (row.id) {
data.id = row.id;
}
const response = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${row.id ? 'update' : 'create'}`, data);
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchLocationThreshold()
}, async deleteRow(index) {
const row = this.rows[index];
if (!row.id) {
this.window.notify('error', 'Eintrag hat keine ID');
}
const response = axios.post(window['TT_CONFIG']['BASE_PATH'] + '/WarehouseArticlePrice/delete', {id: row.id});
this.$emit('doUpdate');
this.window.notify(response.data.success ? 'success' : 'error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message);
await this.fetchLocationThreshold();
}
}, watch: {
show(newVal) {
this.showModal = newVal;
if (!newVal) {
this.rows = [];
return;
}
this.rows = [];
this.fetchLocationThreshold().then();
}
}
})
Vue.component('warehouse-article', {
//language=Vue
template: `
<tt-card>
<tt-table-crud ref="table"
@openHistory="historyModal = true; historyModalId = $event.id"
@editDistributorEntries="distributorModal = true; distributorModalId = $event.id"
@editPricesEntries="priceModal = true; priceModalId = $event.id"
@editThresholdEntries="thresholdModal = true; thresholdModalId = $event.id">
<template v-slot:cheapestPurchasePrice="{ row }">
<span>{{(row.cheapestPurchasePrice * row.sellPriceMultiplier).toFixed(2)}} €</span>
</template>
</tt-table-crud>
<warehouse-distributor-modal :show.sync="distributorModal" :id="distributorModalId" @doUpdate="refreshTable"/>
<warehouse-threshold-modal :show.sync="thresholdModal" :id="thresholdModalId" @doUpdate="refreshTable"/>
<warehouse-article-price-modal :show.sync="priceModal" :id="priceModalId" @doUpdate="refreshTable"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window,
historyModal: false,
historyModalId: null,
distributorModal: false,
distributorModalId: null,
thresholdModal: false,
thresholdModalId: null,
priceModal: false,
priceModalId: null
}
}, methods: {
refreshTable() {
this.$refs.table.$refs.table.refreshTable();
}
}
})

View File

@@ -0,0 +1,13 @@
Vue.component('warehouse-distributor', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,130 @@
Vue.component('tt-expandable-shopping-cart', {
props: {
cartItems: Array,
},
data() {
return {
isExpanded: false,
};
},
methods: {
},
template: `
<div class="tt-expandable-shopping-cart" :class="{ expanded: isExpanded }">
<button class="toggle-button" @click="isExpanded = !isExpanded">
<i class="fas fa-shopping-cart text-primary"></i>
<span v-if="cartItems.length > 0 && isExpanded" class="btn btn-primary" @click.prevent="$emit('submitOrder')">Bestellen</span>
<span class="cart-count" v-if="cartItems.length > 0">{{ cartItems.length }}</span>
<!-- add arrow down icon when cart is expanded additionally with v-if -->
<i v-if="isExpanded" class="fas fa-arrow-down text-danger" style="font-size:21px;grid-column: 4;"></i>
</button>
<div class="cart-content" v-if="isExpanded">
<div v-if="cartItems.length > 0">
<h3>Einkaufswagen</h3>
<ul class="list-group">
<template v-for="item in cartItems">
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ item.title }}
<span class="badge badge-primary badge-pill">{{ item.amount }}</span>
</li>
</template>
</ul>
</div>
<p v-else>Der Einkaufswagen ist leer.</p>
</div>
</div>
`
});
Vue.component('warehouse-e-shop', {
//language=Vue
template: `
<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="[
{text: 'Einzelne Adresse', value: 'singleAddress'},
{text: 'Mehrere Adressen', value: 'multipleAddresses'},
]" sm row/>
<tt-input v-model="createOrderDialogData.deliveryAddressName" label="Name" 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-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.id]"/>
</template>
<template v-slot:add="{ row }">
<a style="cursor: pointer;" @click="addToCart(row)">
<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',
deliveryAddressName: '',
deliveryAddressLine: '',
deliveryAddressPLZ: '',
deliveryAddressCity: '',
},
}
},
methods: {
async openOrderDialog() {
this.createOrderDialog = true;
},
async submitOrder() {
const response = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseEShopOrder/createOrder`, {
shoppingCart: this.shoppingCart,
deliveryMode: this.createOrderDialogData.deliveryMode,
deliveryAddressName: this.createOrderDialogData.deliveryAddressName,
deliveryAddressLine: this.createOrderDialogData.deliveryAddressLine,
deliveryAddressPLZ: this.createOrderDialogData.deliveryAddressPLZ,
deliveryAddressCity: this.createOrderDialogData.deliveryAddressCity,
});
if (response.data.success) {
this.window.notify('success', response.data.message || 'Erfolgreich gespeichert');
this.shoppingCart = [];
this.createOrderDialog = false;
} else {
this.window.notify('error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message ||
'Ein Fehler ist aufgetreten');
}
},
addToCart(row) {
if (!this.itemAmounts[row.id] || this.itemAmounts[row.id] === 0) {
window.notify('error', 'Bitte geben Sie eine Menge ein.');
return;
}
// Check if Article is already in the shopping cart
if (this.shoppingCart.some(item => item.itemId === row.id)) {
window.notify('warning', `${row.title} ist bereits im Warenkorb.`);
return;
}
this.shoppingCart.push({itemId: row.id, title: row.title, amount: this.itemAmounts[row.id]});
window.notify('success', `${this.itemAmounts[row.id]}x ${row.title} wurde dem Warenkorb hinzugefügt.`);
}
},
})

View File

@@ -0,0 +1,19 @@
Vue.component('warehouse-e-shop-order', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id">
<template v-slot:create="{ row }">
{{ new Date(row.create * 1000).toLocaleDateString() }}
</template>
</tt-table-crud>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,45 @@
Vue.component('warehouse-history-modal', {
//language=Vue
template: `
<tt-modal :show.sync="showModal" title="History" :delete="false" :save="false"
@close="$emit('update:show', false)"
>
<tt-table
v-if="showModal"
:fetch-url="window['TT_CONFIG']['BASE_URL'] + '/getHistory?id=' + id"
:config="WarehouseHistoryTableConfig" small>
<template v-slot:create="{ row }">
{{ window.moment(row.create * 1000).format('DD.MM.YYYY HH:mm:ss') }}
</template>
</tt-table>
</tt-modal>
`,
props: {
show: { type: Boolean, default: false },
id: { type: Number, default: null },
},
data() {
return {
window: window,
showModal: false,
WarehouseHistoryTableConfig: {
key: 'WarehouseHistory',
tableHeader: 'Lager Historie',
headers: [
{text: "Zeit", key: "create", filter: false, sortable: false},
{text: "Feld", key: "columnHeader", filter: false, sortable: false},
{text: "Alter Wert", key: "old_value", filter: false, sortable: false},
{text: "Neuer Wert", key: "new_value", filter: false, sortable: false},
{text: "Geändert von", key: "user_name", filter: false, sortable: false},
],
}
}
},
watch: {
show(newVal) {
this.showModal = newVal
}
}
})

View File

@@ -0,0 +1,13 @@
Vue.component('warehouse-item', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,13 @@
Vue.component('warehouse-location', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,12 @@
Vue.component('warehouse-order-recommendation', {
//language=Vue
template: `
<tt-card>
<tt-table-crud @openHistory="historyModal = true; historyModalId = $event.id"/>
</tt-card>
`, data() {
return {
window: window, historyModal: false, historyModalId: null,
}
},
})

View File

@@ -0,0 +1,6 @@
axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
window.notify('error', error.response.data.message || 'Ein Fehler ist aufgetreten')
return Promise.reject(error);
});

View File

@@ -2,6 +2,10 @@
position: relative;
}
.tt-table {
/*width: unset !important;*/
}
.tt-table.loading tbody:after {
position: absolute;
top: 0;
@@ -152,3 +156,174 @@ input[type=number]::-webkit-outer-spin-button {
td {
vertical-align: middle !important;
}
/*TODO: export this classes somewhere else*/
.text-cyan {
color: var(--cyan);
}
.text-green {
color: var(--green);
}
.text-red {
color: var(--red);
}
.text-yellow {
color: var(--yellow);
}
.text-blue {
color: var(--blue);
}
.text-purple {
color: var(--purple);
}
.text-orange {
color: var(--orange);
}
.text-pink {
color: var(--pink);
}
.text-gray {
color: var(--gray);
}
.text-white {
color: var(--white);
}
.text-light {
color: var(--light);
}
.text-dark {
color: var(--dark);
}
.width-40 {
width: 40px;
}
.width-80 {
width: 80px;
}
.width-120 {
width: 120px;
}
.width-160 {
width: 160px;
}
.width-200 {
width: 200px;
}
.width-240 {
width: 240px;
}
.width-280 {
width: 280px;
}
.width-320 {
width: 320px;
}
.width-360 {
width: 360px;
}
/*TODO: remove this*/
/* Styles for the component */
.tt-expandable-shopping-cart {
position: fixed;
bottom: 65px;
right: 20px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
width: 50px; /* Initial width */
height: 50px;
transition: width 0.3s ease, height 0.3s ease;
}
.expanded {
width: 500px; /* Expanded width */
height: 600px; /* Expanded height */
z-index: 1000;
}
.toggle-button {
background: none;
border: none;
cursor: pointer;
font-size: 24px;
padding: 10px;
}
.cart-content {
padding: 15px;
display: none; /* Initially hidden */
}
.expanded .cart-content {
display: block; /* Show when expanded */
}
.cart-count {
background-color: #dc3545; /* Red background */
color: white;
border-radius: 50%;
padding: 0 6px;
font-size: 12px;
position: absolute;
top: 5px;
right: 5px;
}
.tt-expandable-shopping-cart.expanded {
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
}
.tt-expandable-shopping-cart.expanded .cart-content {
padding-top: 8px;
}
.tt-expandable-shopping-cart.expanded .cart-content h3 {
margin-top: 0;
}
.tt-expandable-shopping-cart.expanded .toggle-button {
display: grid;
grid-template-columns: 2fr 3fr auto auto;
grid-gap: 10px;
width: 100%;
justify-content: space-between;
align-items: center;
background-color: var(--gray)
}
.tt-expandable-shopping-cart.expanded .toggle-button i {
justify-self: left;
}
.tt-expandable-shopping-cart.expanded .cart-count {
position: unset !important;
width: 21px;
height: 21px;
grid-column-start: 3;
}

View File

@@ -1,12 +1,15 @@
Vue.component('tt-autocomplete', {
template: `
<div class="form-group">
<div class="form-group" :class="{'row': row}">
<slot name="prepend"></slot>
<label v-if="label" :for="label">{{ label }}</label>
<div class="autocomplete position-relative">
<label :class="{'col-form-label': row, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
v-if="label" :for="label">{{ label }}</label>
<div class="autocomplete position-relative" :class="{'col-sm-8 p-0': row}">
<input
type="text"
class="form-control form-control-sm"
:id="label"
class="form-control"
:class="{'form-control-sm': sm}"
v-model="displayValue"
@input="onInput"
@focus="onFocus"
@@ -50,18 +53,12 @@ Vue.component('tt-autocomplete', {
// TODO: Implement giving the option without the need of an API || need to use computed property to filter the items
// TODO: Fix the weirdness with timeout and selecting the suggestion
props: {
value: {
type: [String, Number]
},
label: {
type: String,
required: false,
},
value: { type: [String, Number] },
label: { type: String, required: false },
apiUrl: String,
items: {
type: Array,
default: () => [],
}
items: { type: Array, default: () => [] },
sm: { type: Boolean, default: true },
row: { type: Boolean, default: false },
},
async mounted() {
if (this.value && this.apiUrl) {
@@ -70,6 +67,8 @@ Vue.component('tt-autocomplete', {
} else if (this.value) {
const selectedItem = this.items.find(item => item.value === this.value);
this.displayValue = selectedItem ? selectedItem.text : '';
} else {
this.displayValue = '';
}
},
@@ -86,6 +85,7 @@ Vue.component('tt-autocomplete', {
watch: {
value(newValue) {
const selectedItem = this.displayingItems.find(item => item.value === newValue);
console.log(selectedItem);
this.displayValue = selectedItem ? selectedItem.text : '';
},
apiUrl() {
@@ -95,7 +95,7 @@ Vue.component('tt-autocomplete', {
methods: {
onInput(event) {
this.displayValue = event.target.value;
this.$emit('input', undefined);
this.$emit('input', '');
this.fetchSuggestions();
},
onFocus() {

View File

@@ -0,0 +1,42 @@
Vue.component('tt-checkbox', {
props: {
label: String,
required: Boolean,
row: Boolean,
value: [String, Number],
hint: String,
additionalProps: Object,
sm: { type: Boolean, default: false },
},
data() {
return {
checkedValue: this.value,
};
},
watch: {
value(val) {
this.checkedValue = val;
}
},
template: `
<div class="form-group" :class="{'row': row}">
<slot name="prepend"></slot>
<label
:class="{'col-form-label': row, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
v-if="label"
:for="label">{{ label }}</label>
<input type="checkbox"
:id="label"
:name="label"
class="form-control float-left"
:class="{'form-control-sm': sm, 'col-sm-8': row}"
:required="required"
v-bind="additionalProps"
v-model="checkedValue"
@input="$emit('input', $event.target.checked ? 1 : 0)"
>
<small v-if="hint" class="form-text text-muted">{{ hint }}</small>
</div>
`
});

View File

@@ -4,6 +4,7 @@ Vue.component('tt-input', {
type: String,
placeholder: String,
required: Boolean,
row: Boolean,
value: [String, Number],
hint: String,
additionalProps: Object,
@@ -20,15 +21,17 @@ Vue.component('tt-input', {
}
},
template: `
<div class="form-group">
<div class="form-group" :class="{'row': row}">
<slot name="prepend"></slot>
<label
:class="{'col-form-label': row, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
v-if="label"
:for="label">{{ label }}</label>
<input :type="type"
:id="label"
:name="label"
class="form-control"
:class="{'form-control-sm': sm}"
:class="{'form-control-sm': sm, 'col-sm-8': row}"
:placeholder="placeholder"
:required="required"
v-bind="additionalProps"

View File

@@ -0,0 +1,47 @@
Vue.component('tt-modal', {
props: {
show: { type: Boolean, default: false },
title: { type: String, default: 'Modal Title' },
delete: { type: Boolean, default: true },
save: { type: Boolean, default: true },
},
watch: {
show(newVal) {
if (!newVal) {
this.$emit('close')
}
},
},
//language=Vue
template: `
<div class="modal show d-block"
role="dialog"
tabindex="-1"
style="background: rgba(0, 0, 0, 0.5);"
ref="modal"
@mousedown="$emit('update:show', false)"
@keydown.esc="$emit('update:show', false)"
v-if="show">
<div class="modal-dialog modal-lg" role="document" @mousedown.stop>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{title}}</h5>
<button type="button" class="close" @click="$emit('update:show', false)">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button v-if="save" class="btn btn-primary" @click="$emit('submit')">Save</button>
<button v-if="$props.delete" class="btn btn-danger" @click="$emit('delete')">Delete</button>
<button class="btn btn-secondary" @click="$emit('update:show', false)">Close</button>
</slot>
</div>
</div>
</div>
</div>
`
})

View File

@@ -1,5 +1,13 @@
Vue.component('tt-select', {
props: ['options', 'label', 'required', 'value', 'suffix'],
props: {
options: {type: Array, required: true},
label: {type: String, required: false},
required: {type: Boolean, default: false},
value: {type: [String, Number], required: false},
suffix: {type: String, required: false},
sm: {type: Boolean, default: false},
row: {type: Boolean, default: false},
},
data() {
return {
selectedOption: undefined,
@@ -14,9 +22,13 @@ Vue.component('tt-select', {
},
},
template: `
<div class="form-group">
<label v-if="label" :for="label">{{ label }}</label>
<select class="form-control form-control-sm" :required="required" v-model="selectedOption"
<div class="form-group" :class="{'row': row}">
<label v-if="label"
:class="{'col-form-label': row, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
:for="label">{{ label }}</label>
<select class="form-control" :class="{'form-control-sm': sm, 'col-sm-8': row}"
:required="required" v-model="selectedOption"
@change="$emit('input', $event.target.value ? $event.target.value : undefined)">
<template v-for="option of options">
<option v-if="['string','number'].includes(typeof option)" :value="option">{{ option }}

View File

@@ -0,0 +1,122 @@
Vue.component('tt-table-crud', {
//language=Vue
template: `
<div>
<tt-table :fetch-url="window['TT_CONFIG']['TABLE_URL']" :config="tableConfig"
small ssr ref="table">
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="openCrudModal" v-if="crudConfig.createText !== false">
<i class="fas fa-plus"></i>
{{crudConfig.createText || 'Erstellen'}}
</button>
<slot name="table-top-buttons"></slot>
</template>
<template v-for="column in crudConfig.columns"
:slot="column.key.toLowerCase()"
slot-scope="{row}">
<slot v-if="$scopedSlots[column.key.toLowerCase()] && column.modal?.type !== 'select'"
:name="column.key.toLowerCase()" :row="row">
</slot>
<span v-else-if="column.modal?.type === 'select' && column.modal.items.find(item => item.value + '' === row[column.key] + '')">{{column.modal.items.find(item => item.value == row[column.key]).text}}</span>
<span v-else-if="column.modal?.type === 'select'"
:data-all-items="JSON.stringify(column.modal.items)"
>Select not found for column {{column.key}} and value {{row[column.key]}}</span>
</template>
<template v-slot:actions="{ row }">
<!-- calculate min width 1 + number of actions * 19 -->
<div style="display: flex; justify-content: space-around; align-items: center;" :style="{minWidth: (1 + crudConfig?.additionalActions?.length || 0) * 19 + 'px'}">
<a style="cursor: pointer;" @click="openCrudModal(row)"><i class="far fa-edit text-primary"
title="Editieren"></i></a>
<!-- v-for action crudConfig.additionalActions -> v-if action.condition(row) || true , action.class for icon class, action.title for title, action.key for emitting event -->
<a v-for="action in crudConfig.additionalActions"
v-if="!action.condition || action.condition(row)"
style="cursor: pointer;" @click="$emit(action.key, row)">
<i :class="action.class" :title="action.title"></i>
</a>
</div>
</template>
</tt-table>
<tt-modal :show.sync="crudModal"
:title="crudConfig.createText || 'Erstellen'"
@submit="submitCrudModal"
@delete="deleteCrudModal"
@close="resetCrudModalData">
<template v-for="column in modalConfig.headers">
<!-- @formatter:off -->
<tt-input v-if="column.type === 'text'" v-model="crudModalData[column.key]" :label="column.text" sm row/>
<tt-input v-if="column.type === 'number'" v-model="crudModalData[column.key]" :label="column.text" type="number" sm row/>
<tt-textarea v-if="column.type === 'textarea'" v-model="crudModalData[column.key]" :label="column.text" sm row/>
<tt-select v-if="column.type === 'select'" v-model="crudModalData[column.key]" :label="column.text" :options="column.items" sm row/>
<tt-autocomplete v-if="column.type === 'autocomplete'" v-model="crudModalData[column.key]" :label="column.text" :items="column.items" sm row/>
<tt-date-picker v-if="column.type === 'datepicker'" v-model="crudModalData[column.key]" :label="column.text" sm row/>
<tt-icon-select v-if="column.type === 'icon-select'" v-model="crudModalData[column.key]" :label="column.text" sm row/>
<tt-checkbox v-if="column.type === 'checkbox'" v-model="crudModalData[column.key]" :label="column.text" sm row/>
<!-- @formatter:on -->
</template>
</tt-modal>
</div>
`, props: {
crudConfig: {type: Object, required: false, default: () => (window['TT_CONFIG']['CRUD_CONFIG'])}
}, data() {
return {
crudModal: false, crudModalData: {}, window: window
}
}, methods: {
openCrudModal(row = null) {
this.crudModalData = row ? JSON.parse(JSON.stringify(row)) : {};
this.crudModal = true;
}, resetCrudModalData() {
this.crudModalData = {};
this.crudModal = false;
}, async submitCrudModal() {
delete this.crudModalData.isTrusted;
const response = await axios.post(this.crudModalData.id ? window['TT_CONFIG']['UPDATE_URL'] : window['TT_CONFIG']['CREATE_URL'],
this.crudModalData);
if (response.data.success) {
this.$refs.table.refreshTable();
this.resetCrudModalData();
this.window.notify('success', response.data.message || 'Erfolgreich gespeichert');
} else {
this.window.notify('error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message ||
'Ein Fehler ist aufgetreten');
}
}, async deleteCrudModal() {
const response = await axios.post(window['TT_CONFIG']['DELETE_URL'], {id: this.crudModalData.id});
if (response.data.success) {
this.$refs.table.refreshTable();
this.resetCrudModalData();
}
this.window.notify(response.data.success ? 'success' : 'error', response.data.message);
}
}, computed: {
tableConfig() {
return {
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}
})
}
}, modalConfig() {
return {
key: this.crudConfig.key,
headers: this.crudConfig.columns.filter(column => column.modal !== false).map(column => {
const type = column.modal?.type || "text"
return {text: column.text, key: column.key, type, ...column.modal}
}),
}
}
}
})

View File

@@ -49,9 +49,11 @@ Vue.component('tt-table-pagination', {
}
return pages.length === 0 ? [1] : pages;
}, pageInfoText() {
const start = Math.min(this.pagination.page * this.pagination.per_page - this.pagination.per_page + 1, this.pagination.total_rows);
const start = Math.min(this.pagination.page * this.pagination.per_page - this.pagination.per_page + 1,
this.pagination.total_rows);
const end = Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total_rows);
const total = this.pagination.total_rows === this.pagination.filtered_available ? this.pagination.total_rows : `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
const total = this.pagination.total_rows ===
this.pagination.filtered_available ? this.pagination.total_rows : `${this.pagination.filtered_available} (${this.pagination.total_rows})`;
return `${start} bis ${end} von ${total}`;
}
}, methods: {
@@ -60,12 +62,14 @@ Vue.component('tt-table-pagination', {
}
}, template: `
<div class="tt-table-pagination-container">
<div v-if="pagination && typeof pagination.total_rows === 'number'" class="tt-table-pagination-wrapper">
<div v-if="pagination && typeof pagination.total_rows === 'number'"
class="tt-table-pagination-wrapper">
<span class="tt-table-text-center" v-text="pageInfoText"
:style="{ 'grid-row': reverse ? 2 : 1, 'grid-column': 1 }"></span>
<ul class="pagination tt-table-pagination" :style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
<ul class="pagination tt-table-pagination"
:style="{ 'grid-row': reverse ? 1 : 2, 'grid-column': 1 }">
<li class="page-item tt-table-page-item" v-bind:class="{ disabled: pagination.page === 1 }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(1)" aria-label="First">
<span aria-hidden="true">&laquo;</span>
@@ -119,7 +123,8 @@ Vue.component('tt-table', {
<div class="tt-table-top-pagination-container">
<!-- if excelExport is true, show the export button fontawesome icon excel -->
<div style="display:flex;align-items: center;">
<i v-if="!Object.values(columns).every(column => column.filter === false)" title="Filter zurücksetzen"
<i v-if="!Object.values(columns).every(column => column.filter === false)"
title="Filter zurücksetzen"
@click="resetTable" class="fas fa-times cursor-pointer text-danger"
style="font-size: 24px;margin-right: 6px;cursor: pointer; color: var(--orange)"></i>
<i v-if="excelExport" title="EXCEL Export" @click="exportToExcel" class="fa fa-file-excel"
@@ -152,19 +157,14 @@ Vue.component('tt-table', {
v-if="column.sortable"
:class="getSortIconClass(column.key)"></i>
</div>
<tt-input v-if="column.filter === 'search'" sm v-model="filters[column.key]"></tt-input>
<tt-icon-select v-else-if="column.filter === 'iconSelect'" :options="column.filterOptions"
v-model="filters[column.key]"></tt-icon-select>
<tt-number-range v-else-if="column.filter === 'numberRange'" :returnText="!ssr"
v-model="filters[column.key]"></tt-number-range>
<tt-select v-else-if="column.filter === 'select'"
:options="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
v-model="filters[column.key]"></tt-select>
<tt-autocomplete v-else-if="column.filter === 'autocomplete'"
:items="[{text: 'Alle', value: undefined}, ...column.filterOptions]"
v-model="filters[column.key]"></tt-autocomplete>
<tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"></tt-date-picker>
<!-- @formatter:off -->
<tt-input v-if="column.filter === 'search'" v-model="filters[column.key]" sm/>
<tt-icon-select v-else-if="column.filter === 'iconSelect'" :options="column.filterOptions" v-model="filters[column.key]" sm/>
<tt-number-range v-else-if="column.filter === 'numberRange'" :returnText="!ssr" v-model="filters[column.key]" sm/>
<tt-select v-else-if="column.filter === 'select'" :options="[{text: 'Alle', value: undefined}, ...column.filterOptions]" v-model="filters[column.key]" sm/>
<tt-autocomplete v-else-if="column.filter === 'autocomplete'" :items="[{text: 'Alle', value: undefined}, ...column.filterOptions]" v-model="filters[column.key]" sm/>
<tt-date-picker v-else-if="column.filter === 'date'" v-model="filters[column.key]"/>
<!-- @formatter:on -->
</th>
</tr>
</thead>
@@ -184,7 +184,8 @@ Vue.component('tt-table', {
<td :class="{ 'text-center': column.filter === 'iconSelect',
[columns[key].class]: true,
//'text-nowrap': !originalColumnWidths[column.key]
}">
}"
>
<!-- If td is first of row then check isExpanded and display fas.fa-chevron-right or fas.fa-chevron-down with cursor pointer -->
<i v-if="key === Object.keys(columns)[0] &&
($scopedSlots.expandedRow && (typeof config.expandCondition !== 'function' || config.expandCondition(row)) || hiddenColumns.length > 0)"
@@ -194,11 +195,19 @@ Vue.component('tt-table', {
style="cursor: pointer;font-size: 14px;padding-right: 8px;user-select: none"></i>
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
<span
v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
v-if="column.filter === 'date'">{{
row[key] ? (moment.unix(row[key])
.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>
<span v-else
<span v-else-if="column.filter === 'autocomplete'">{{
columns[key].filterOptions.find(option => option.value.toString() ===
row[key].toString())?.text
}}</span>
<span v-else-if="row[key] !== null"
v-html="(column.prefix) + (row[key] === null || typeof row[key] === 'undefined' ? '' : row[key]?.toString()?.replace('\\\\n', '<br>')) + (column.suffix )"></span>
</slot>
@@ -216,11 +225,15 @@ Vue.component('tt-table', {
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
<span
v-if="column.filter === 'date'">{{ row[key] ? (moment.unix(row[key]).isValid() ? moment.unix(row[key]).format('DD.MM.YYYY HH:mm') : moment(row[key]).format('DD.MM.YYYY HH:mm')) : '' }}</span>
v-if="column.filter === 'date'">{{
row[key] ? (moment.unix(row[key])
.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>
<span v-else
<span v-else-if="row[key] !== null"
v-html="(column.prefix) + (row[key] === null || typeof row[key] === 'undefined' ? '' : row[key]?.toString()?.replace('\\\\n', '<br>')) + (column.suffix )">
</span>
</slot>
@@ -271,7 +284,8 @@ Vue.component('tt-table', {
isInitialised: false,
hiddenColumns: [],
originalColumnWidths: {},
originalTableWidth: null
originalTableWidth: null,
debouncedHandleResize: null
};
},
@@ -290,7 +304,12 @@ Vue.component('tt-table', {
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(() => fn.apply(context, args), wait);
}
}, /**
},
refreshTable() {
this.loading = true;
this.fetchData(this.pagination.page).then()
},
/**
* Fetches and updates data for a specified page.
*
* @param {number} page The page number to fetch data for.
@@ -428,7 +447,9 @@ Vue.component('tt-table', {
if (!this.columns[key]) continue;
if (this.columns[key] && this.columns[key].filter === 'iconSelect') {
parsedRow[this.columns[key].text] = this.columns[key].filterOptions.find(option => option.value.toString() === row[key].toString())?.text;
parsedRow[this.columns[key].text] =
this.columns[key].filterOptions.find(option => option.value.toString() ===
row[key].toString())?.text;
} else if (this.columns[key] && this.columns[key].filter === 'date') {
parsedRow[this.columns[key].text] = this.moment(row[key]).format('DD.MM.YYYY HH:mm');
} else {
@@ -472,15 +493,19 @@ Vue.component('tt-table', {
this.disableDebounce = true;
window.notify('success', 'Filter zurückgesetzt');
}, toggleExpand(index) {
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows, index, true);
this.expandedRows[index] ? this.$delete(this.expandedRows, index) : this.$set(this.expandedRows,
index,
true);
}, isExpanded(index) {
return !!this.expandedRows[index];
},
async handleResponsiveColumns() {
const tableContainerComputedStyle = window.getComputedStyle(this.$refs.tableContainer);
let viewportWidth = this.$refs.tableContainer.offsetWidth -
parseInt(tableContainerComputedStyle.paddingLeft) -
parseInt(tableContainerComputedStyle.paddingRight);
if (!this.$refs.tableContainer) return;
const tableContainer = this.$refs.tableContainer;
const {paddingLeft, paddingRight} = window.getComputedStyle(tableContainer);
let viewportWidth = tableContainer.offsetWidth - parseInt(paddingLeft) - parseInt(paddingRight);
this.originalColumnWidths = {}
this.hiddenColumns = []
await this.$nextTick()
@@ -581,7 +606,9 @@ Vue.component('tt-table', {
}, pagesToDisplay() {
let range = 2; // Number of pages before and after the current page
let start = (this.pagination.page < 4 ? 1 : this.pagination.page - range);
let end = (this.pagination.page + range > this.pagination.total_pages ? this.pagination.total_pages : this.pagination.page + range);
let end = (this.pagination.page +
range >
this.pagination.total_pages ? this.pagination.total_pages : this.pagination.page + range);
if (end < 5) end = 5;
// Adjust start and end if they are out of bounds
@@ -647,10 +674,13 @@ Vue.component('tt-table', {
if (header.filter === 'search') {
const isNegated = filterValue.startsWith('!');
const substrings = (isNegated ? filterValue.slice(1) : filterValue).split(' ').map(s => s.toLowerCase());
const substrings = (isNegated ? filterValue.slice(1) : filterValue).split(' ')
.map(s => s.toLowerCase());
const targetValue = !row[header.key] ? '' :
typeof row[header.key] === 'object' ? Object.values(row[header.key]).join(' ').toLowerCase() : row[header.key].toString().toLowerCase();
typeof row[header.key] === 'object' ? Object.values(row[header.key])
.join(' ')
.toLowerCase() : row[header.key].toString().toLowerCase();
let substringMatch = true;
for (var k = 0, klen = substrings.length; k < klen; ++k) {
@@ -673,7 +703,12 @@ Vue.component('tt-table', {
match = false;
break;
}
} else if (header.filter === 'select' || header.filter === 'iconSelect' || header.filter === 'autocomplete') {
} else if (header.filter ===
'select' ||
header.filter ===
'iconSelect' ||
header.filter ===
'autocomplete') {
if (filterValue === '') continue;
if (filterValue !== row[header.key]?.toString()) {
match = false;
@@ -682,7 +717,8 @@ Vue.component('tt-table', {
} else if (header.filter === 'date') {
if (!filterValue.from || !filterValue.to) continue;
const dateInt = row[header.key].length === 10 ? parseInt(row[header.key]) * 1000 : parseInt(row[header.key]);
const dateInt = row[header.key].length === 10 ? parseInt(row[header.key]) *
1000 : parseInt(row[header.key]);
let rowDate = new Date(dateInt).getTime() / 1000;
if (rowDate < filterValue.from || rowDate > filterValue.to) {
@@ -708,10 +744,12 @@ Vue.component('tt-table', {
output.sort((a, b) => {
let valueA = isDateColumn ?
new Date(a[this.order.key].length === 10 ? parseInt(a[this.order.key]) * 1000 : parseInt(a[this.order.key])).getTime() :
new Date(a[this.order.key].length === 10 ? parseInt(a[this.order.key]) *
1000 : parseInt(a[this.order.key])).getTime() :
a[this.order.key] || ''
let valueB = isDateColumn ?
new Date(b[this.order.key].length === 10 ? parseInt(b[this.order.key]) * 1000 : parseInt(b[this.order.key])).getTime() :
new Date(b[this.order.key].length === 10 ? parseInt(b[this.order.key]) *
1000 : parseInt(b[this.order.key])).getTime() :
b[this.order.key] || ''
if (valueA === valueB) return 0;
@@ -757,26 +795,28 @@ Vue.component('tt-table', {
// use header#topnav as top to stick to but if window is resized then check if header#topnav height is changed
const headerHeight = document.querySelector('header#topnav')?.offsetHeight || 0;
style.innerHTML = `table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
style.innerHTML =
`table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
style.id = 'tt-table-sticky-header';
window.addEventListener('resize', () => {
const headerHeight = document.querySelector('header#topnav')?.offsetHeight || 0;
style.innerHTML = `table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
style.innerHTML =
`table thead th { position: sticky; top: ${headerHeight}px; z-index: 1; background-color: white; }`;
})
document.head.appendChild(style);
}
window.addEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
this.debouncedHandleResize = this.debounce(this.handleResponsiveColumns, 100);
window.addEventListener('resize', this.debouncedHandleResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.debounce(this.handleResponsiveColumns, 100));
window.removeEventListener('resize', this.debouncedHandleResize);
}
})
Vue.config.errorHandler = function(err, vm, info) {
Vue.config.errorHandler = function (err, vm, info) {
// still log errors to the console
console.error(info, err, vm);