diff --git a/Layout/default/HistoricTicket/Index.php b/Layout/default/HistoricTicket/Index.php new file mode 100644 index 000000000..5fa77b806 --- /dev/null +++ b/Layout/default/HistoricTicket/Index.php @@ -0,0 +1,163 @@ + 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"); ?> + +
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/application/HistoricTicket/HistoricTicket.php b/application/HistoricTicket/HistoricTicket.php new file mode 100644 index 000000000..ad3fb26f3 --- /dev/null +++ b/application/HistoricTicket/HistoricTicket.php @@ -0,0 +1,9 @@ +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) + ] + ]; + } +} \ No newline at end of file diff --git a/application/HistoricTicket/HistoricTicketModel.php b/application/HistoricTicket/HistoricTicketModel.php new file mode 100644 index 000000000..6be6d317f --- /dev/null +++ b/application/HistoricTicket/HistoricTicketModel.php @@ -0,0 +1,174 @@ + $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; + } +} \ No newline at end of file diff --git a/db/migrations/20240312154600_add_historic_ticket.php b/db/migrations/20240312154600_add_historic_ticket.php new file mode 100644 index 000000000..e718571d3 --- /dev/null +++ b/db/migrations/20240312154600_add_historic_ticket.php @@ -0,0 +1,82 @@ +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") { + + } + } +} diff --git a/public/plugins/vue/tt-components/css/tt-table.css b/public/plugins/vue/tt-components/css/tt-table.css new file mode 100644 index 000000000..a7d6e9c3a --- /dev/null +++ b/public/plugins/vue/tt-components/css/tt-table.css @@ -0,0 +1,20 @@ +.tt-table.loading tbody { + position: relative; +} + +.tt-table.loading tbody:after { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.1); + background-image: url(data:image/gif;base64,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); + background-position: center; + background-repeat: no-repeat; + background-size: 50px 50px; + content: ""; +} +.tt-table-card .page-link { + padding: 5px .75rem !important; +} \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-table.js b/public/plugins/vue/tt-components/tt-table.js new file mode 100644 index 000000000..b0f92ab53 --- /dev/null +++ b/public/plugins/vue/tt-components/tt-table.js @@ -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: ` +
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
{{ column.text }}
+ + +
Keine Ergebnisse!
Laden...
+
+
+ `, 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(); + }, +}) \ No newline at end of file