Merge branch 'feature/add-historic-tickets' into 'master'

Feature/add historic tickets

See merge request fronk/thetool!292
This commit is contained in:
Frank Schubert
2024-03-12 15:53:46 +00:00
7 changed files with 810 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
<?php /** @noinspection PhpUndefinedClassInspection
* @var string $mfLayoutPackage
* @var TYPE_NAME $git_merge_ts
*/
//additional css /css/views/RaspberryDisplay.css
$JSGlobals = ["BASE_URL" => self::getUrl("Domain"),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
"PAGE_TITLE" => "Historische Tickets",
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
["text" => "Historische Tickets", "href" => self::getUrl("HistoricTicket")]
],
"HISTORIC_TICKET_API_URL" => self::getUrl("HistoricTicket/api"),
];
$additionalJS = ["plugins/vue/vue.js",
"plugins/axios/axios.min.js",
"plugins/vue/tt-components/tt-page-title.js",
"plugins/vue/tt-components/tt-table.js",
];
$additionalCSS = [
'plugins/vue/tt-components/css/tt-table.css',
];
include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
<div id="app">
<!-- start page title -->
<tt-page-title :title="window['TT_CONFIG']['PAGE_TITLE']" :path="window['TT_CONFIG']['PATH']"></tt-page-title>
<tt-table :fetch-url="window['TT_CONFIG']['HISTORIC_TICKET_API_URL'] + '?do=getHistoricTickets'"
:table-config="historicTicketTableConfig"
small ref="table">
<template v-slot:top-buttons>
<!-- add input for global search with label and bootstrap class-->
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Globale Suche" v-model="globalSearch">
<div class="input-group-append">
<button class="btn btn-primary" type="button" @click="doGlobalSearch">Submit</button>
</div>
</div>
</template>
<template v-slot:ticket_number="{ row }">
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
</template>
</tt-table>
<!-- Bootstrap Modal to show global search results -->
<div class="modal show d-block" tabindex="-1" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
v-if="globalSearchModal">
<div class="modal-dialog" role="document"
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Suchergebnisse</h5>
<button type="button" class="close" @click="globalSearchModal = false">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<tt-table
:fetch-url="`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=findHistoricTicket&query=${globalSearch}`"
:table-config="globalSearchModalTableConfig"
small ref="table">
<template v-slot:ticket_number="{ row }">
<a href="#" @click="clickTicketNumber(row.ticket_number)">{{ row.ticket_number }}</a>
</template>
</tt-table>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="globalSearchModal = false">Close</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap Modal to show ticket messages -->
<div class="modal show d-block" tabindex="-1" role="dialog" style="background: rgba(0, 0, 0, 0.5);"
v-if="selectedTicketNumber">
<div class="modal-dialog" role="document"
style="width:fit-content;max-width: 80vw ; max-height: 80vh; overflow-y: auto;">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ticket {{ selectedTicketNumber }}</h5>
<button type="button" class="close" @click="selectedTicketNumber = null">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div v-if="selectedTicketData">
<h5>{{ selectedTicketData.ticket.subject }}</h5>
<p>{{ selectedTicketData.ticket.message }}</p>
<div v-for="message in selectedTicketData.messages">
<hr>
<p style="word-break: break-all;">{{ message.content }}</p>
</div>
</div>
<div v-else class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
new Vue({
el: '#app',
data: {
window: window,
selectedTicketNumber: null,
selectedTicketData: null,
globalSearch: '',
globalSearchModal: false,
globalSearchModalTableConfig: {
headers: [
{text: 'Ticket Number', key: 'ticket_number', filter: 'search'},
{text: 'Subject', key: 'ticket_subject', filter: 'search'},
{text: 'Message', key: 'ticket_message', filter: 'search'},
],
tableHeader: 'Suchergebnisse',
},
historicTicketTableConfig: {
headers: [
{text: 'Ticket Number', key: 'ticket_number', filter: 'search'},
{text: 'Subject', key: 'subject', filter: 'search'},
{text: 'Type', key: 'type', filter: 'search'},
{text: 'Status', key: 'status', filter: 'search'},
{text: 'Name', key: 'first_name', filter: 'search'},
{text: 'Email', key: 'email', filter: 'search'},
{text: 'Phone', key: 'phone', filter: 'search'},
],
defaultPageSize: 25,
tableHeader: 'Bestellungen',
}
},
methods: {
async doGlobalSearch() {
if (this.globalSearch.length > 3) {
this.globalSearchModal = true;
}
},
async clickTicketNumber(ticketNumber) {
this.globalSearchModal = false;
this.selectedTicketData = null;
this.selectedTicketNumber = ticketNumber;
const response = await axios.post(`${window['TT_CONFIG']['HISTORIC_TICKET_API_URL']}?do=getHistoricTicketMessages`, {ticketNumber});
this.selectedTicketData = response.data;
}
}
})
</script>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>

View File

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

View File

@@ -0,0 +1,103 @@
<?php
class HistoricTicketController extends mfBaseController {
private User $me;
protected function init(): void {
$me = new User();
$me->loadMe();
$this->layout()->set("me", $me);
$this->me = $me;
}
protected function indexAction(): void {
if (!$this->me->is("employee")) {
$this->redirect("dashboard");
}
$this->layout()->setTemplate("HistoricTicket/Index");
}
protected function apiAction() {
$do = $this->request->do;
if ($do !== "getConfig" && !$this->me->is("employee")) {
$this->redirect("dashboard");
}
switch ($do) {
case "getHistoricTickets":
$return = $this->getHistoricTickets();
break;
case "getHistoricTicketMessages":
$return = $this->getHistoricTicketMessages();
break;
case "findHistoricTicket":
$return = $this->findHistoricTicket();
break;
default:
$return = false;
break;
}
if (!$return) {
$return = [
"status" => "error",
"message" => "Invalid request."
];
}
die(json_encode($return));
}
private function getHistoricTickets(): array {
$json = json_decode(file_get_contents('php://input'), true);
$filters = $json['filters'] ?? [];
$page = $json['pagination']['page'] ?? 1;
$perPage = $json['pagination']['per_page'] ?? 10;
$historicTickets = HistoricTicketModel::getAllHistoricTickets($filters, $perPage, ($page - 1) * $perPage);
$total = HistoricTicketModel::countHistoricTickets($filters);
return [
"rows" => $historicTickets,
"pagination" => [
"page" => $page,
"total_pages" => ceil($total / $perPage),
"per_page" => $perPage,
"total_rows" => intval($total)
]
];
}
private function getHistoricTicketMessages(): array {
$json = json_decode(file_get_contents('php://input'), true);
$ticketNumber = $json['ticketNumber'];
return HistoricTicketModel::findHistoricTicket($ticketNumber);
}
private function findHistoricTicket(): array {
$query = $this->request->query;
if (empty($query)) {
return [
"status" => "error",
"message" => "No query provided."
];
}
$rows = HistoricTicketModel::findTicket($query);
return [
"rows" => $rows,
"pagination" => [
"page" => 1,
"total_pages" => 1,
"per_page" => count($rows),
"total_rows" => count($rows)
]
];
}
}

View File

@@ -0,0 +1,174 @@
<?php
class HistoricTicketModel {
public $id;
public $ticket_number;
public $ticket_verifier;
public $priority;
public $status_id;
public $status;
public $type_id;
public $type;
public $user_id;
public $agent_id;
public $contact_id;
public $company;
public $company_id;
public $first_name;
public $middle_name;
public $last_name;
public $email;
public $phone;
public $subject;
public $ctime;
public $mtime;
public $muser_id;
public $files_folder_id;
public $unseen;
public $group_id;
public $order_id;
public $last_response_time;
public $cc_addresses;
public function __construct($data = []) {
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
}
}
}
public static function generateFilterCondition(?string $filterValue, string $columnName): string {
$sql = "";
if (!empty($filterValue)) {
$filterItems = explode(" ", $filterValue);
foreach ($filterItems as $item) {
$sql .= " AND `$columnName` LIKE '%" . $item . "%'";
}
}
return $sql;
}
public static function getSqlFilter($filters): string {
$sql = isset($filters['subject']) ? self::generateFilterCondition($filters['subject'], "subject") : "";
$sql .= isset($filters['ticket_number']) ? self::generateFilterCondition($filters['ticket_number'], "ticket_number") : "";
$sql .= isset($filters['priority']) ? " AND `priority` = " . $filters['priority'] : "";
$sql .= isset($filters['status']) ? self::generateFilterCondition($filters['status'], "status") : "";
$sql .= isset($filters['status_id']) ? " AND `status_id` = " . $filters['status_id'] : "";
$sql .= isset($filters['type']) ? self::generateFilterCondition($filters['type'], "type") : "";
$sql .= isset($filters['type_id']) ? " AND `type_id` = " . $filters['type_id'] : "";
$sql .= isset($filters['user_id']) ? " AND `user_id` = " . $filters['user_id'] : "";
$sql .= isset($filters['agent_id']) ? " AND `agent_id` = " . $filters['agent_id'] : "";
$sql .= isset($filters['contact_id']) ? " AND `contact_id` = " . $filters['contact_id'] : "";
$sql .= isset($filters['company']) ? self::generateFilterCondition($filters['company'], "company") : "";
$sql .= isset($filters['company_id']) ? " AND `company_id` = " . $filters['company_id'] : "";
$sql .= isset($filters['first_name']) ? self::generateFilterCondition($filters['first_name'], "first_name") : "";
$sql .= isset($filters['middle_name']) ? self::generateFilterCondition($filters['middle_name'], "middle_name") : "";
$sql .= isset($filters['last_name']) ? self::generateFilterCondition($filters['last_name'], "last_name") : "";
$sql .= isset($filters['email']) ? " AND `email` LIKE '%" . $filters['email'] . "%'" : "";
$sql .= isset($filters['phone']) ? " AND `phone` LIKE '%" . $filters['phone'] . "%'" : "";
$sql .= isset($filters['ctime']) ? " AND `ctime` = " . $filters['ctime'] : "";
$sql .= isset($filters['mtime']) ? " AND `mtime` = " . $filters['mtime'] : "";
$sql .= isset($filters['muser_id']) ? " AND `muser_id` = " . $filters['muser_id'] : "";
$sql .= isset($filters['files_folder_id']) ? " AND `files_folder_id` = " . $filters['files_folder_id'] : "";
$sql .= isset($filters['unseen']) ? " AND `unseen` = " . $filters['unseen'] : "";
$sql .= isset($filters['group_id']) ? " AND `group_id` = " . $filters['group_id'] : "";
$sql .= isset($filters['order_id']) ? " AND `order_id` = " . $filters['order_id'] : "";
$sql .= isset($filters['last_response_time']) ? " AND `last_response_time` = " . $filters['last_response_time'] : "";
$sql .= isset($filters['cc_addresses']) ? self::generateFilterCondition($filters['cc_addresses'], "cc_addresses") : "";
return $sql;
}
public static function getAllHistoricTickets($filters, $limit = null, $offset = 0): array {
$db = FronkDB::singleton();
$sql = "SELECT * FROM `HistoricTicket` WHERE 1 " . self::getSqlFilter($filters) . " ORDER BY `ticket_number` DESC";
$sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset;
$result = $db->query($sql);
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
return $rows;
}
public static function countHistoricTickets($filters) {
$db = FronkDB::singleton();
$sql = "SELECT COUNT(*) as `total_rows` FROM `HistoricTicket` WHERE 1 " . self::getSqlFilter($filters);
$result = $db->query($sql);
return $result->fetch_assoc()['total_rows'];
}
public static function findHistoricTicket($ticketNumber): array {
$db = FronkDB::singleton();
$ticketSql = "SELECT * FROM `HistoricTicket` WHERE `ticket_number` = " . $ticketNumber;
$ticketResult = $db->query($ticketSql);
$ticket = $ticketResult->fetch_assoc();
$messagesSql = "SELECT * FROM `HistoricTicketMessage` WHERE `ticket_id` = " . $ticket["id"];
$messagesResult = $db->query($messagesSql);
$messages = [];
while ($message = $messagesResult->fetch_assoc()) {
$messages[] = $message;
}
return [
"ticket" => $ticket,
"messages" => $messages
];
}
public static function findTicket($query): array {
$db = FronkDB::singleton();
$ticketSql = "SELECT * FROM `HistoricTicket` WHERE `ticket_number` LIKE '%" . $query . "%' OR `subject` LIKE '%" . $query . "%' OR `first_name` LIKE '%" . $query . "%' OR `last_name` LIKE '%" . $query . "%'";
$ticketResult = $db->query($ticketSql);
$tickets = [];
while ($ticket = $ticketResult->fetch_assoc()) {
$tickets[] = $ticket;
}
//explore $query by space and add each word to the sql query with and
$query = explode(" ", $query);
$whereStr = "";
foreach ($query as $word) {
$whereStr .= " AND `content` LIKE '%" . $word . "%'";
}
$messagesSql = "SELECT * FROM `HistoricTicketMessage`
LEFT JOIN `HistoricTicket` ON `HistoricTicket`.`id` = `HistoricTicketMessage`.`ticket_id`
WHERE 1 AND `content` " . $whereStr;
$messagesResult = $db->query($messagesSql);
$messages = [];
while ($message = $messagesResult->fetch_assoc()) {
$messages[] = $message;
}
$return = [];
foreach ($tickets as $ticket) {
$return[] = [
"table_entry" => "ticket",
"ticket_id" => $ticket["id"],
"ticket_number" => $ticket["ticket_number"],
"ticket_subject" => $ticket["subject"],
];
}
foreach ($messages as $message) {
$return[] = [
"table_entry" => "message",
"ticket_id" => $message["ticket_id"],
"ticket_number" => $message["ticket_number"],
"ticket_subject" => $message["subject"],
"ticket_message" => $message["content"],
];
}
return $return;
}
}

View File

@@ -0,0 +1,82 @@
<?php /** @noinspection ALL */
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddHistoricTicket extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
//HistoricTicket Table
$historicTicket = $this->table("HistoricTicket", ["signed" => true]);
$historicTicket->addColumn("ticket_number", "", ["null" => true]);
$historicTicket->addColumn("ticket_verifier", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("priority", "", ["null" => false, "default" => "1"]);
$historicTicket->addColumn("status_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("status", "string", ["null" => true]);
$historicTicket->addColumn("type_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("type", "string", ["null" => true]);
$historicTicket->addColumn("user_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("agent_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("contact_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("company", "", ["null" => false]);
$historicTicket->addColumn("company_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("first_name", "", ["null" => false]);
$historicTicket->addColumn("middle_name", "", ["null" => false]);
$historicTicket->addColumn("last_name", "", ["null" => false]);
$historicTicket->addColumn("email", "", ["null" => false]);
$historicTicket->addColumn("phone", "", ["null" => false]);
$historicTicket->addColumn("subject", "", ["null" => false]);
$historicTicket->addColumn("ctime", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("mtime", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("muser_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("files_folder_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("unseen", "", ["null" => false, "default" => "1"]);
$historicTicket->addColumn("group_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("order_id", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("last_response_time", "", ["null" => false, "default" => "0"]);
$historicTicket->addColumn("cc_addresses", "string", ["null" => false]);
$historicTicket->save();
//HistoricTicketMessage Table
$historicTicketMessage = $this->table("HistoricTicketMessage", ["signed" => true]);
$historicTicketMessage->addColumn("ticket_id", "", ["null" => false]);
$historicTicketMessage->addColumn("status_id", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("type_id", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("has_status", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("has_type", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("content", "text", ["null" => true]);
$historicTicketMessage->addColumn("attachments", "", ["null" => false]);
$historicTicketMessage->addColumn("is_note", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("user_id", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("ctime", "", ["null" => false]);
$historicTicketMessage->addColumn("mtime", "", ["null" => false]);
$historicTicketMessage->addColumn("rate_id", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("rate_amount", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("rate_hours", "", ["null" => false, "default" => "0"]);
$historicTicketMessage->addColumn("rate_name", "", ["null" => false]);
$historicTicketMessage->addColumn("rate_cost_code", "", ["null" => true]);
$historicTicketMessage->save();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table("HistoricTicket")->drop()->save();
$this->table("HistoricTicketMessage")->drop()->save();
}
if($this->getEnvironment() == "addressdb") {
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,259 @@
//TODO: tt-autocomplete , tt-select aswell as tt-input should be used for filtering
//TODO: Add sorting functionality
//TODO: Add export to excel and pdf functionality
//TODO: Add Date-Range filter
//TODO: Add Exact Date filter
//TODO: Add new prop serverSide to disable pagination and filtering on the client side
//TODO: Add filtering function if serverSide is disabled
//TODO: Add JSDoc for various functions and props
/**
* @typedef {Object} ttTableColumnConfig
* @property {string} text - The display text of the column.
* @property {string} key - The unique key of the column.
* @property {string} filter - Indicates if filtering is enabled for the column.
* @property {boolean} sortEnabled - Indicates if sorting is enabled for the column.
* @property {string} class - The CSS class(es) applied to the column.
*/
Vue.component('tt-table', {
template: `
<div class="card tt-table-card">
<div class="card-body">
<!-- Top Buttons -->
<div
style="display:grid; grid-template-columns: auto auto auto auto auto; grid-gap: 8px; padding-bottom: 8px">
<button v-if="excelExport" class="btn btn-success" @click="exportToExcel">
<i class="fa fa-file
"></i>
Excel
</button>
<button v-if="pdfExport" class="btn btn-danger" @click="exportToPdf">
<i class="fa fa-file
"></i>
PDF
</button>
<slot name="top-buttons"></slot>
</div>
<!-- Pagination Controls -->
<nav aria-label="Page navigation">
<div
style="display:grid; grid-template-columns: 1fr 1fr;padding-bottom: 8px;align-items:center; justify-content: space-between">
<h4 style="margin: 0">{{ tableConfig.tableHeader }}</h4>
<div v-if="pagination && pagination.total_rows > 0"
style="display:grid; grid-template-rows: auto auto; grid-template-columns: auto auto; grid-auto-flow: column; grid-gap: 4px; justify-content: end">
<ul class="pagination" style="margin: 0">
<li class="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>
<span class="sr-only">First</span>
</a>
</li>
<li class="page-item" v-for="pageNumber in pagesToDisplay"
v-bind:class="{ 'active disabled': pageNumber === pagination.page }">
<a class="page-link disabled" href="#"
v-on:click.prevent="fetchRows(pageNumber)">{{ pageNumber }}</a>
</li>
<li class="page-item" v-bind:class="{ disabled: pagination.page === pagination.total_pages }">
<a class="page-link" href="#" v-on:click.prevent="fetchRows(pagination.total_pages)"
aria-label="Last">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Last</span>
</a>
</li>
</ul>
<span class="text-center"
v-text="Math.min(pagination.page * pagination.per_page - pagination.per_page + 1, pagination.total_rows)
+ ' bis ' + Math.min(pagination.page * pagination.per_page, pagination.total_rows) + ' von ' + pagination.total_rows"></span>
<select v-model="pagination.per_page" v-on:change="fetchRows(1)" class="form-control form-control-sm">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<span class="text-center">Einträge pro Seite</span>
</div>
</div>
</nav>
<!-- Table -->
<table
:class="['table','tt-table','table-condensed',{ 'loading': loading },{ 'table-striped': striped },{ 'table-bordered': bordered },{ 'table-hover': hover },{ 'table-sm': small }]">
<thead>
<tr>
<th scope="col" v-for="column in columns" style="vertical-align: top; text-align: center">
<div style="text-align:center">{{ column.text }}</div>
<input v-if="column.filter === 'search'" type=text v-on:input="applyFilter($event, column.key)"
class="form-control form-control-sm">
<select v-if="column.filter === 'select'" v-on:change="applyFilter($event, column.key)"
class="form-control form-control-sm">
<option value="all">Alle</option>
<option v-for="filterOption in column.filterOptions" :value="filterOption.value">
{{ filterOption.text }}
</option>
</select>
</th>
</tr>
</thead>
<tbody>
<tr v-if="pagination?.total_rows === 0" style="height: 150px">
<td :colspan="Object.keys(columns).length" :rowspan="5" class="text-center">Keine Ergebnisse!</td>
</tr>
<tr v-else-if="pagination === null || rows === null" style="height: 150px">
<td :colspan="Object.keys(columns).length" class="text-center">Laden...</td>
</tr>
<tr v-for="row in rows">
<!--suppress JSUnusedLocalSymbols -->
<template v-for="(value, key) in columns">
<td :class="columns[key].class">
<slot :name="key.toLowerCase()" :value="row[key]" :row="row">
<span
v-html="row[key] === null || typeof row[key] === 'undefined' ? null : row[key]?.toString()?.replace('\\n', '<br>')"></span>
</slot>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
`, props: {
fetchUrl: String, striped: {
type: Boolean, default: true
}, bordered: {
type: Boolean, default: true
}, hover: {
type: Boolean, default: true
}, small: Boolean, excelExport: Boolean, pdfExport: Boolean, tableConfig: {
type: Object, default: () => ({})
}
}, data() {
return {
loading: false, rows: null, pagination: null, filters: {}, debounceTimeout: null, latestFetchTimestamp: null
};
},
methods: {
/**
* Creates a debounced function that delays invoking `fn` until after `wait` milliseconds
* have elapsed since the last time the debounced function was invoked.
*
* @param {Function} fn The function to debounce.
* @param {number} wait The number of milliseconds to delay.
* @return {Function} The debounced function.
*/
debounce(fn, wait) {
return function (...args) {
const context = this;
if (this.debounceTimeout) clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(() => fn.apply(context, args), wait);
}
}, /**
* Fetches and updates data for a specified page.
*
* @param {number} page The page number to fetch data for.
* @async
*/
async fetchData(page) {
try {
const fetchTimestamp = Date.now();
this.latestFetchTimestamp = fetchTimestamp;
const response = await axios.post(this.fetchUrl, {
pagination: {
page: Math.max(page, 1), per_page: this.pagination?.per_page ? this.pagination.per_page : 10,
}, filters: this.filters,
});
if (fetchTimestamp !== this.latestFetchTimestamp) return;
if (typeof response.data !== 'object') { // if the response is not an object
this.rows = [];
this.pagination = {page: 1, per_page: 10, total_rows: 0, total_pages: 1};
} else {
this.rows = response.data.rows;
this.pagination = response.data.pagination;
}
this.loading = false;
} catch (error) {
console.error('Error fetching data:', error);
}
}, /**
* Fetches rows for a given page, with an option to debounce the fetch operation.
*
* @param {number} page The page number to fetch. Defaults to 1.
* @param {boolean} debounce Whether to debounce the fetch operation. Defaults to false.
*/
async fetchRows(page = 1, debounce = false) {
this.loading = true
if (debounce) {
this.debounce(this.fetchData.bind(this), 300)(page);
} else {
await this.fetchData(page); // Directly call fetchData without debounce
}
},
applyFilter(event, key) {
this.$set(this.filters, key, event.target.value); // Ensure reactivity
}
}, watch: {
filters: {
handler: function () {
this.fetchRows(this.pagination.page, true).then();
}, deep: true
}
}, computed: {
/**
* Returns an object containing the columns' configuration.
* @return {ttTableColumnConfig} The columns configuration.
*/
columns() {
return this.tableConfig.headers.reduce((columns, column) => {
if (!column.key) {
console.warn('WARN: tt-table: Column text or key is not defined:', column);
return columns; // Continue to the next iteration without modifying the accumulator
}
columns[column.key] = {
text: column.text,
key: column.key,
filter: column.filter !== undefined ? column.filter : 'search',
filterOptions: column.filterOptions || undefined,
sortEnabled: column.sortEnabled !== undefined ? column.sortEnabled : true,
class: column.class !== undefined ? column.class : ''
};
return columns;
}, {});
}, 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);
if (end < 5) end = 5;
// Adjust start and end if they are out of bounds
end = end > this.pagination.total_pages ? this.pagination.total_pages : end;
// Adjust the start and end if we are at the end of the page range
if (this.pagination.page > this.pagination.total_pages - 2) {
start = this.pagination.total_pages - 4 < 1 ? 1 : this.pagination.total_pages - 4;
}
// Create an array of page numbers to display
let pagesArray = [];
for (let i = start; i <= end; i++) {
pagesArray.push(i);
}
return pagesArray;
}
}, mounted() {
if(this.tableConfig.defaultPageSize) {
this.pagination = {page: 1, per_page: this.tableConfig.defaultPageSize, total_rows: null, total_pages: 1};
}
this.fetchRows().then();
},
})